@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.43
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/managed/report-filter.ts +2 -1
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/out/{chunk-WOTMRDM3.js → chunk-7RU5EDRO.js} +61 -61
- package/out/{chunk-D7AF2JXL.js → chunk-A7M4GV7M.js} +1 -1
- package/out/{chunk-DGVQPIYB.js → chunk-LAAB5SZT.js} +1 -1
- package/out/{chunk-YQRVLRDH.js → chunk-NWZ4PVPN.js} +1 -1
- package/out/{chunk-YP2537WX.js → chunk-OSIMGFUF.js} +1 -1
- package/out/{chunk-Q7ZSIEHP.js → chunk-XOCAPDCM.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +2 -2
- package/out/view.js +54 -54
- package/package.json +9 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +60 -14
- package/risk/npm/dependencies.js +11 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/http.ts +63 -26
- package/server-managed/locked-packages.ts +18 -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/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 +255 -201
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
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.43",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -152,6 +152,7 @@
|
|
|
152
152
|
"server-managed/rename-diff.ts",
|
|
153
153
|
"server-managed/rename-diff-worker.js",
|
|
154
154
|
"server-managed/public-workspace.ts",
|
|
155
|
+
"server-managed/queue.ts",
|
|
155
156
|
"server-managed/repo-path.ts",
|
|
156
157
|
"server-managed/report-migration.ts",
|
|
157
158
|
"server-managed/report-query.ts",
|
|
@@ -164,6 +165,9 @@
|
|
|
164
165
|
"server-managed/repository-discovery.ts",
|
|
165
166
|
"server-managed/repository-policy.ts",
|
|
166
167
|
"server-managed/repository-risk.ts",
|
|
168
|
+
"server-managed/repository-risk-assess.ts",
|
|
169
|
+
"server-managed/repository-risk-audit.ts",
|
|
170
|
+
"server-managed/scan-downloads.ts",
|
|
167
171
|
"server-managed/session.ts",
|
|
168
172
|
"server-managed/slugs.ts",
|
|
169
173
|
"server-managed/solidity-entry-points.ts",
|
|
@@ -255,6 +259,8 @@
|
|
|
255
259
|
"common/revalidation-input-groups.js",
|
|
256
260
|
"common/server-info.ts",
|
|
257
261
|
"common/scan-server.ts",
|
|
262
|
+
"common/security-links.d.ts",
|
|
263
|
+
"common/security-links.js",
|
|
258
264
|
"common/utf8.d.ts",
|
|
259
265
|
"common/utf8.js",
|
|
260
266
|
"common/workspace-app.js",
|
|
@@ -346,9 +352,9 @@
|
|
|
346
352
|
"dependencies": {
|
|
347
353
|
"@exodus/stasis": "1.0.0-beta.9",
|
|
348
354
|
"@exodus/stasis-core": "1.0.0-beta.9",
|
|
349
|
-
"@preventive/compare": "^1.2.
|
|
355
|
+
"@preventive/compare": "^1.2.8",
|
|
350
356
|
"@preventive/diff": "^1.0.2",
|
|
351
|
-
"@preventive/lockfile": "1.
|
|
357
|
+
"@preventive/lockfile": "1.1.0",
|
|
352
358
|
"@preventive/publishers": "^1.1.0",
|
|
353
359
|
"@preventive/report": "1.0.0-alpha.9",
|
|
354
360
|
"@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
|
@@ -12,13 +12,14 @@ import { NpmRiskError, npmDownloads, npmPackageFacts, npmPackageReleases, npmPub
|
|
|
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,32 @@ 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`), the version its spec installs (`installs`,
|
|
131
|
+
// registry.js npmPublicFacts); `missing` where
|
|
132
|
+
// its package was looked up and npm has none of it for anyone, as once
|
|
133
|
+
// unpublished or taken down; four asked at a time, whether each is the
|
|
129
134
|
// caller's own among them, none more once one fails or MAX_TOLD_DEPENDENCIES
|
|
130
135
|
// not the caller's own were taken, however many a version adds.
|
|
131
|
-
async function addedDependencies(added, published, context, owned) {
|
|
136
|
+
async function addedDependencies(added, published, context, owned, unusedKeys = new Set()) {
|
|
132
137
|
const told = [...added]
|
|
133
138
|
const asked = told.flatMap((row, at) => row.opens ? [at] : [])
|
|
134
139
|
let next = 0, taken = 0
|
|
135
140
|
const tell = async row => {
|
|
136
141
|
const downloads = await npmDownloads(row.opens, context)
|
|
137
|
-
|
|
142
|
+
const unused = row.flags.some(flag => flag.check === 'unimported') || unusedKeys.has(keyOf(row))
|
|
143
|
+
let facts = npmAddedDependency({ downloads, published, unused }, context.now), missing = false
|
|
138
144
|
if (facts.wanted) {
|
|
139
|
-
const listed = await npmPublicFacts(row.opens, context)
|
|
140
|
-
|
|
145
|
+
const listed = await npmPublicFacts(row.opens, context, unused ? row.range : null)
|
|
146
|
+
missing = listed === null
|
|
147
|
+
facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published, unused }, context.now)
|
|
141
148
|
}
|
|
142
149
|
const settled = npmSettledDownloads(downloads, context.now)
|
|
143
150
|
const month = npmDownloadMonths(settled).at(-1)
|
|
144
151
|
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 } : {} }
|
|
152
|
+
...facts.created ? { created: facts.created, gap: facts.gap } : {}, ...facts.installs ? { installs: facts.installs } : {}, ...missing ? { missing } : {} }
|
|
146
153
|
}
|
|
147
154
|
await Promise.all(Array.from({ length: 4 }, async () => {
|
|
148
155
|
while (next < asked.length && taken < MAX_TOLD_DEPENDENCIES) {
|
|
@@ -201,21 +208,60 @@ export async function assessNpm(name, version, { oldVersion = null, fromEmpty =
|
|
|
201
208
|
const own = npmVersionFindings({ deprecated: facts.deprecated[version], published, minimumReleaseAge, now, known: Boolean(manifest.knownAccounts), scope: npmKnownScope(name),
|
|
202
209
|
provenance: Boolean(manifest.provenance), downloads: counted })
|
|
203
210
|
const was = old === null ? null : facts.versions.get(old).published
|
|
204
|
-
|
|
205
|
-
|
|
211
|
+
// Those it holds that nothing imports by another spec than the version
|
|
212
|
+
// compared with's, told by their new spec as an added one nothing imports
|
|
213
|
+
// is (`respecified`), and an added one nothing imports however it was
|
|
214
|
+
// flagged, as one moved from another kind of dependency is.
|
|
215
|
+
const unusedKeys = unusedKeysOf(kept)
|
|
216
|
+
const respecified = kept.dependencies.changed.filter(row => unusedKeys.has(keyOf(row))).map(row => ({ ...row, range: row.to }))
|
|
217
|
+
const told = downloads && dependencies ? await addedDependencies([...kept.dependencies.added, ...respecified], [was, published], context, owned, unusedKeys)
|
|
218
|
+
: [...kept.dependencies.added, ...respecified]
|
|
219
|
+
const added = told.slice(0, kept.dependencies.added.length)
|
|
220
|
+
const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added, told.slice(added.length))
|
|
206
221
|
return { name, version, oldVersion: old, published, ...assessment, score: npmRiskScore(assessment.findings, { published, now, tier: counted ? npmDownloadTier(counted) : null }) }
|
|
207
222
|
}
|
|
208
223
|
|
|
224
|
+
// A dependency's key among a version's, by its kind and name.
|
|
225
|
+
const keyOf = row => `${row.kind ?? ''}\0${row.name}`
|
|
226
|
+
|
|
227
|
+
// The keys of the dependencies a version holds that nothing of it imports
|
|
228
|
+
// (`kept`, npmLastingRisk's: its `held-unused-deps`).
|
|
229
|
+
const unusedKeysOf = kept => new Set((kept.findings.find(({ id }) => id === 'held-unused-deps')?.dependencies ?? []).map(keyOf))
|
|
230
|
+
|
|
231
|
+
// The dependencies nothing of a version imports (`held-unused-deps`), each
|
|
232
|
+
// graded by what risk tells of its package fresh (`told`, as
|
|
233
|
+
// addedDependencies tells it; new-dependency.js
|
|
234
|
+
// npmUnusedDependencySeverity), with its tier, when its package was first
|
|
235
|
+
// published and the version its spec installs where told: kept from the
|
|
236
|
+
// version compared with where there was one (`compared`) and it named it as
|
|
237
|
+
// the same kind of dependency by the same spec: not one marked new, nor
|
|
238
|
+
// `added` (its kind and name new, as one moved from another kind is), nor
|
|
239
|
+
// `changed` (each said with the spec it had, `was`). The finding as its most severe.
|
|
240
|
+
function unusedGraded(finding, told, compared, { added, changed }) {
|
|
241
|
+
const facts = new Map(told.map(row => [keyOf(row), row])), was = new Map(changed.map(row => [keyOf(row), row.from]))
|
|
242
|
+
const anew = new Set(added.map(keyOf))
|
|
243
|
+
const dependencies = finding.dependencies.map(row => {
|
|
244
|
+
const of = facts.get(keyOf(row))
|
|
245
|
+
return { ...row, ...was.has(keyOf(row)) ? { was: was.get(keyOf(row)) } : {}, severity: npmUnusedDependencySeverity(of, compared && !row.added && !anew.has(keyOf(row)) && !was.has(keyOf(row))),
|
|
246
|
+
...of?.level ? { tier: of.tier, level: of.level } : {}, ...of?.created ? { created: of.created, gap: of.gap } : {}, ...of?.installs ? { installs: of.installs } : {},
|
|
247
|
+
...of?.missing ? { missing: true } : {} }
|
|
248
|
+
})
|
|
249
|
+
return { ...finding, severity: SEVERITIES[Math.min(...dependencies.map(row => SEVERITIES.indexOf(row.severity)))], dependencies }
|
|
250
|
+
}
|
|
251
|
+
|
|
209
252
|
// An assessment of what of it holds whatever the time (`kept`,
|
|
210
253
|
// npmLastingRisk's) and what is told fresh: the open version's own findings
|
|
211
254
|
// (`own`, held.js npmVersionFindings, and its malware), its malware and
|
|
212
255
|
// deprecation first and the rest last, its tier as JSON holds it (tierOf),
|
|
213
|
-
// and its dependencies `added`, as addedDependencies tells them
|
|
256
|
+
// and its dependencies `added`, as addedDependencies tells them, those it
|
|
257
|
+
// holds by another spec that nothing imports beside them (`respecified`),
|
|
258
|
+
// for its unused dependencies' grades. The managed
|
|
214
259
|
// server's (assessNpm), and a test's of versions it holds.
|
|
215
260
|
const FIRST = new Set(['malware', 'deprecated'])
|
|
216
|
-
export function npmAssessmentOf(kept, own, added = kept.dependencies.added) {
|
|
261
|
+
export function npmAssessmentOf(kept, own, added = kept.dependencies.added, respecified = []) {
|
|
217
262
|
return {
|
|
218
|
-
findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings
|
|
263
|
+
findings: [...own.filter(({ id }) => FIRST.has(id)), ...kept.findings.map(finding => finding.id === 'held-unused-deps'
|
|
264
|
+
? unusedGraded(finding, [...added, ...respecified], Boolean(kept.dependencies.held), kept.dependencies) : finding),
|
|
219
265
|
...own.filter(({ id }) => !FIRST.has(id)).map(finding => finding.id === 'popularity' ? { ...finding, ...tierOf(finding.tier) } : finding)],
|
|
220
266
|
files: kept.files,
|
|
221
267
|
dependencies: { ...kept.dependencies, added },
|
package/risk/npm/dependencies.js
CHANGED
|
@@ -108,10 +108,18 @@ 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))
|
|
@@ -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
|
+
}
|