@preventive/triage 1.0.0-alpha.41 → 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-TY4RZD3Z.js → chunk-7RU5EDRO.js} +61 -61
- package/out/{chunk-XL35ZV6R.js → chunk-A7M4GV7M.js} +1 -1
- package/out/{chunk-2AFQKRAM.js → chunk-CLPKNNC7.js} +7 -7
- package/out/{chunk-UZAZM2SX.js → chunk-LAAB5SZT.js} +1 -1
- package/out/{chunk-KVEUXIEU.js → chunk-NWZ4PVPN.js} +1 -1
- package/out/{chunk-QZ3A376W.js → chunk-OSIMGFUF.js} +1 -1
- package/out/{chunk-U6OPEXIA.js → chunk-XOCAPDCM.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +3 -3
- package/out/view.js +54 -54
- package/package.json +9 -3
- package/risk/files/runs-code.js +13 -5
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +60 -14
- package/risk/npm/combined.js +4 -1
- package/risk/npm/compare.js +10 -5
- package/risk/npm/dependencies.js +11 -3
- package/risk/npm/diff.js +42 -6
- package/risk/npm/held.js +5 -2
- 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/pretty-print.ts +1 -1
- 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/type-only-worker.js +3 -3
- package/server-managed/type-only.ts +4 -2
- 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/files/runs-code.js
CHANGED
|
@@ -581,12 +581,20 @@ export function npmFoundAdded(found, was) {
|
|
|
581
581
|
// a tag kinds share once: `eval, child_process`.
|
|
582
582
|
export const npmRunsCodeKinds = founds => [...new Set([...RUNS_CODE].filter(([kind]) => founds.some(found => found.has(kind))).map(([, { tag }]) => tag))].join(', ')
|
|
583
583
|
|
|
584
|
-
// What `found` holds (npmRunsCode's) but the kinds whose tags `tags` names
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
584
|
+
// What `found` holds (npmRunsCode's) but the kinds whose tags `tags` names
|
|
585
|
+
// (npmRunsCodeWithout), or of those kinds alone (npmRunsCodeTagged), each
|
|
586
|
+
// with its lines; null for none.
|
|
587
|
+
function npmRunsCodeOf(found, kept) {
|
|
588
|
+
const of = new Map([...found ?? []].filter(([kind]) => kept(RUNS_CODE.get(kind).tag)))
|
|
589
|
+
return of.size > 0 ? of : null
|
|
589
590
|
}
|
|
591
|
+
export const npmRunsCodeWithout = (found, tags) => npmRunsCodeOf(found, tag => !tags.has(tag))
|
|
592
|
+
export const npmRunsCodeTagged = (found, tags) => npmRunsCodeOf(found, tag => tags.has(tag))
|
|
593
|
+
|
|
594
|
+
// The kinds that reach for credentials, by their tag: a version's finding of
|
|
595
|
+
// their own (risk/npm/held.js `held-credentials`), apart from the code its
|
|
596
|
+
// files run beyond themselves.
|
|
597
|
+
export const CREDENTIAL_TAGS = new Set([CREDENTIALS.tag])
|
|
590
598
|
|
|
591
599
|
// Its tags as a file's flags (file-flags.js), in their order, each once: a
|
|
592
600
|
// warning's (`high`), or for one marked worse, `critical`; none for none.
|
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/combined.js
CHANGED
|
@@ -25,12 +25,15 @@ const higher = (a, b) => SEVERITIES[Math.min(SEVERITIES.indexOf(a), SEVERITIES.i
|
|
|
25
25
|
// scripts added or changed and what each version ran (`flagged`, `was`); a
|
|
26
26
|
// dependency taken from elsewhere than npm's registry anew, its spec before
|
|
27
27
|
// (`was`), or added that nothing imports (`added`). Code kept running beyond
|
|
28
|
-
// itself (`kept-runs-code`)
|
|
28
|
+
// itself or reaching for credentials (`kept-runs-code`, `kept-credentials`)
|
|
29
|
+
// marks nothing, its files held already.
|
|
29
30
|
const byPath = (rows, key) => new Map(rows.map(row => [row.path, row[key]]))
|
|
30
31
|
const marked = (held, rows) => ({ ...held, files: held.files.map(row => rows.has(row.path) ? { ...row, added: rows.get(row.path) } : row) })
|
|
31
32
|
const FOLDS = {
|
|
32
33
|
'runs-code': ['held-runs-code', (held, change) => marked(held, byPath(change.files, 'added'))],
|
|
33
34
|
'kept-runs-code': ['held-runs-code', held => held],
|
|
35
|
+
credentials: ['held-credentials', (held, change) => marked(held, byPath(change.files, 'added'))],
|
|
36
|
+
'kept-credentials': ['held-credentials', held => held],
|
|
34
37
|
obfuscated: ['held-obfuscated', (held, change) => marked(held, byPath(change.files, 'obfuscation'))],
|
|
35
38
|
'install-scripts': ['held-install-scripts', (held, change) => ({ ...held, flagged: change.flagged, was: [...change.was] })],
|
|
36
39
|
'non-registry-deps': ['held-non-registry-deps', (held, change) => {
|
package/risk/npm/compare.js
CHANGED
|
@@ -13,7 +13,7 @@ import { npmHostFinding } from './held.js'
|
|
|
13
13
|
import { HOST_TAGS, npmHostSeverity, npmHostsTagged } from '../files/hosts.js'
|
|
14
14
|
import { npmBecameUnreadable, npmBinaryCode, npmLargeCode, npmUnreadable } from '../files/readability.js'
|
|
15
15
|
import { SEVERITIES } from '../files/stamps.js'
|
|
16
|
-
import { npmFoundAdded, npmRunsCodeFlags, npmRunsCodeWithout } from '../files/runs-code.js'
|
|
16
|
+
import { CREDENTIAL_TAGS, npmFoundAdded, npmRunsCodeFlags, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
|
|
17
17
|
import { npmDependencySpecs } from './dependencies.js'
|
|
18
18
|
import { npmFilesDiff } from './diff.js'
|
|
19
19
|
import { npmInstallScripts } from './install-scripts.js'
|
|
@@ -21,7 +21,8 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
|
|
|
21
21
|
|
|
22
22
|
// What the open version does to its files that calls for a look, as red
|
|
23
23
|
// findings: code that runs beyond itself (runs-code.js), new to a file it
|
|
24
|
-
// adds or changes, the kinds with their lines (`runs-code`)
|
|
24
|
+
// adds or changes, the kinds with their lines (`runs-code`), those reaching
|
|
25
|
+
// for credentials apart (CREDENTIAL_TAGS, `credentials`); a service under
|
|
25
26
|
// one of the hosts' tags new to a file it adds or changes (hosts.js
|
|
26
27
|
// npmExfiltration), a finding a tag a level higher than its own
|
|
27
28
|
// (npmHostSeverity: `exfiltration` and `remote` red, `ip-lookup` orange); a
|
|
@@ -33,7 +34,7 @@ import { npmInstallScriptsChange, npmProvenanceLost, npmPublisherChange } from '
|
|
|
33
34
|
// the one compared with's was text, or no code, or none (npmBinaryCode,
|
|
34
35
|
// `binary-code`). Then in orange what any of its files runs beyond itself that
|
|
35
36
|
// `runs-code` doesn't name, kept from the version compared with
|
|
36
|
-
// (`kept-runs-code`). A renamed file by its old path; one renamed alone,
|
|
37
|
+
// (`kept-runs-code`, `kept-credentials`). A renamed file by its old path; one renamed alone,
|
|
37
38
|
// unchanged, holds what it held. A file become unreadable calls for less
|
|
38
39
|
// where its source map was rebuilt with it (`rebuilt`, rebuiltWithMap), as a
|
|
39
40
|
// build minifies a file and writes its map together, and in a `major`
|
|
@@ -51,8 +52,11 @@ function npmFileFindings(base, other, files, major) {
|
|
|
51
52
|
const exfiltration = gained('exfiltration'), runsCode = gained('runsCode')
|
|
52
53
|
const flagged = new Set(runsCode.flatMap(row => npmRunsCodeFlags(row.added).map(({ name }) => name)))
|
|
53
54
|
const kept = [...bundleFilesAsMap(other).keys()].map(path => ({ path, found: npmRunsCodeWithout(npmVersionFileRisk(other, path).runsCode, flagged) })).filter(row => row.found)
|
|
55
|
+
// Code that runs beyond itself, or of the kinds reaching for credentials.
|
|
56
|
+
const ofKinds = (rows, key, of) => rows.map(row => ({ ...row, [key]: of(row[key], CREDENTIAL_TAGS) })).filter(row => row[key])
|
|
54
57
|
return [
|
|
55
|
-
{ id: 'runs-code', severity: 'critical', files: runsCode },
|
|
58
|
+
{ id: 'runs-code', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeWithout) },
|
|
59
|
+
{ id: 'credentials', severity: 'critical', files: ofKinds(runsCode, 'added', npmRunsCodeTagged) },
|
|
56
60
|
...[...HOST_TAGS.keys()].map(tag => {
|
|
57
61
|
const rows = exfiltration.map(row => ({ path: row.path, added: npmHostsTagged(row.added, tag) })).filter(row => row.added)
|
|
58
62
|
return { id: npmHostFinding(tag), severity: rows.length > 0 ? npmHostSeverity(tag, rows[0].added, null) : HOST_TAGS.get(tag), files: rows }
|
|
@@ -65,7 +69,8 @@ function npmFileFindings(base, other, files, major) {
|
|
|
65
69
|
.map(row => ({ path: row.path, obfuscation: after(row).readability.obfuscation })) },
|
|
66
70
|
{ id: 'binary-code', severity: 'critical', files: touched.filter(row => npmBinaryCode(row.path, after(row).readability) && !npmBinaryCode(row.renamedFrom ?? row.path, before(row)?.readability))
|
|
67
71
|
.map(row => ({ path: row.path, bytes: sizes.get(row.path) })) },
|
|
68
|
-
{ id: 'kept-runs-code', severity: 'high', files: kept },
|
|
72
|
+
{ id: 'kept-runs-code', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeWithout) },
|
|
73
|
+
{ id: 'kept-credentials', severity: 'high', files: ofKinds(kept, 'found', npmRunsCodeTagged) },
|
|
69
74
|
].filter(finding => finding.files.length > 0)
|
|
70
75
|
}
|
|
71
76
|
|
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))
|
package/risk/npm/diff.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// How two versions' files differ, as Compare's diff has them
|
|
2
2
|
// (ui/view/bundle-compare-diff.js computeBundleDiff), for risk's findings
|
|
3
|
-
// (compare.js): the files the open version adds, and those it changes, a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// (compare.js): the files the open version adds, and those it changes, a
|
|
4
|
+
// build's content-hashed file paired with the one it replaces by its name
|
|
5
|
+
// (hashedKey), and a file moved to another path with its old one by content,
|
|
6
|
+
// as git diff -M pairs them (@preventive/compare compareTrees).
|
|
6
7
|
import { compareTrees } from '@preventive/compare'
|
|
7
8
|
import { bundleFilesAsMap } from '../../common/bundle-sources.js'
|
|
8
9
|
|
|
@@ -21,6 +22,25 @@ const same = (a, b) => a === b || (a?.format === 'digest' && b?.format === 'dige
|
|
|
21
22
|
const treeData = (content, at) => typeof content === 'string' ? `\0text\0${content.toWellFormed()}`
|
|
22
23
|
: content?.format === 'digest' ? `\0digest\0${content.digest}` : `\0none\0${at}`
|
|
23
24
|
|
|
25
|
+
// A build's content-hashed file (`assets/index-CyWAfh-p.js`, Vite's and
|
|
26
|
+
// Rollup's `[name]-[hash]`, webpack's `[name].[contenthash].js`) by its name
|
|
27
|
+
// but its hash, `assets/index\0.js`: a release renames it as its content
|
|
28
|
+
// changes, the same file. Null for a name whose last part before its
|
|
29
|
+
// extension is no hash: 8 to 32 of base64url's characters with a letter, and
|
|
30
|
+
// a digit or a capital, as neither a lowercase word (`index-client.js`,
|
|
31
|
+
// `lodash.debounce.js`) nor a number (a date, `migration-20240101.js`) is. Its hash its last 8 or more such characters, so that chunks named
|
|
32
|
+
// after a module (`vendor-react-DQ4mN2sx.js`) pair each with its own; a
|
|
33
|
+
// longer hash holding a `-` (Rollup's `[hash:16]`) is cut there, so that a
|
|
34
|
+
// rebuild pairs by its content alone, as its parts aren't told from a
|
|
35
|
+
// chunk's name's (`vendor-ui-…`) without it.
|
|
36
|
+
const HASHED_NAME = /^(.+)[-.]([\w-]{8,32})((?:\.[A-Za-z\d]+)+)$/u
|
|
37
|
+
function hashedKey(path) {
|
|
38
|
+
const at = path.lastIndexOf('/') + 1
|
|
39
|
+
const [, stem, hash, extension] = HASHED_NAME.exec(path.slice(at)) ?? []
|
|
40
|
+
if (!hash || !/[A-Za-z]/u.test(hash) || !/[\dA-Z]/u.test(hash)) return null
|
|
41
|
+
return `${path.slice(0, at)}${stem}\0${extension}`
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
// `onlyOther`, the files the open version (`other`) adds, `{ path }` each, and
|
|
25
45
|
// `changed`, those it changes, `{ path, modified }`, or moves, `{ path,
|
|
26
46
|
// renamedFrom, modified }`, `modified` whether its content changed too; each
|
|
@@ -34,10 +54,26 @@ export function npmFilesDiff(base, other) {
|
|
|
34
54
|
else if (!same(before.get(path), text)) changed.push({ path, modified: true })
|
|
35
55
|
}
|
|
36
56
|
for (const path of before.keys()) if (!after.has(path)) onlyBase.push(path)
|
|
57
|
+
// A content-hashed file added where one by the same name but its hash
|
|
58
|
+
// (hashedKey) is removed, one each, is that file changed, as a build
|
|
59
|
+
// renames it: what it runs, how it reads and how large it is told against
|
|
60
|
+
// it, not all of it new.
|
|
61
|
+
const hashed = paths => Map.groupBy(paths.filter(hashedKey), hashedKey)
|
|
62
|
+
const moved = new Set(), removed = hashed(onlyBase)
|
|
63
|
+
for (const [key, paths] of hashed(onlyOther)) {
|
|
64
|
+
const was = removed.get(key)
|
|
65
|
+
if (paths.length !== 1 || was?.length !== 1) continue
|
|
66
|
+
changed.push({ path: paths[0], renamedFrom: was[0], modified: !same(before.get(was[0]), after.get(paths[0])) })
|
|
67
|
+
moved.add(paths[0])
|
|
68
|
+
}
|
|
69
|
+
// The rest by content, against every file removed: one paired by its name
|
|
70
|
+
// may be another's too, a build's copy of it elsewhere.
|
|
37
71
|
const entries = (paths, files, side) => paths.filter(path => path.isWellFormed()).map((path, at) => ({ name: path, data: treeData(files.get(path), `${side}${at}`) }))
|
|
38
|
-
const renames = compareTrees(entries(onlyBase, before, 'a'), entries(onlyOther, after, 'b'), { paths: 'keys', renameLimit: RENAME_LIMIT, lines: false })
|
|
72
|
+
const renames = compareTrees(entries(onlyBase, before, 'a'), entries(onlyOther.filter(path => !moved.has(path)), after, 'b'), { paths: 'keys', renameLimit: RENAME_LIMIT, lines: false })
|
|
39
73
|
.filter(change => change.status === 'renamed')
|
|
40
|
-
const
|
|
41
|
-
|
|
74
|
+
for (const change of renames) {
|
|
75
|
+
changed.push({ path: change.new.path, renamedFrom: change.old.path, modified: change.similarity !== 100 })
|
|
76
|
+
moved.add(change.new.path)
|
|
77
|
+
}
|
|
42
78
|
return { onlyOther: onlyOther.filter(path => !moved.has(path)).map(path => ({ path })), changed }
|
|
43
79
|
}
|
package/risk/npm/held.js
CHANGED
|
@@ -7,6 +7,7 @@ import { npmDownloadTier, npmTierLevel, npmTierLow } from './downloads.js'
|
|
|
7
7
|
import { npmInstallScripts } from './install-scripts.js'
|
|
8
8
|
import { npmTooNew } from './release.js'
|
|
9
9
|
import { HOST_TAGS, npmHostsTagged } from '../files/hosts.js'
|
|
10
|
+
import { CREDENTIAL_TAGS, npmRunsCodeTagged, npmRunsCodeWithout } from '../files/runs-code.js'
|
|
10
11
|
import { npmVersionFileRisk } from './files.js'
|
|
11
12
|
|
|
12
13
|
// What npm says of a version and of its package, told fresh as it changes in
|
|
@@ -39,7 +40,8 @@ export function npmVersionFindings({ deprecated, published, minimumReleaseAge, n
|
|
|
39
40
|
// orange, as its files' tags are: the scripts npm runs installing it
|
|
40
41
|
// (install-scripts.js, `held-install-scripts`, `[script, command]` each); and
|
|
41
42
|
// its files, each with what it holds, that run code beyond themselves
|
|
42
|
-
// (`held-runs-code`),
|
|
43
|
+
// (`held-runs-code`), reach for credentials (runs-code.js CREDENTIAL_TAGS,
|
|
44
|
+
// `held-credentials`), name a service under one of the hosts' tags, at the
|
|
43
45
|
// tag's severity (hosts.js HOST_TAGS: `held-exfiltration` red, `held-remote`
|
|
44
46
|
// orange, `held-ip-lookup` yellow), or bear an obfuscator's fingerprint
|
|
45
47
|
// (`held-obfuscated`).
|
|
@@ -49,7 +51,8 @@ export function npmHeldFindings(version) {
|
|
|
49
51
|
const holding = of => reads.map(({ path, read }) => ({ path, found: of(read) })).filter(row => row.found)
|
|
50
52
|
return [
|
|
51
53
|
...scripts.length > 0 ? [{ id: 'held-install-scripts', severity: 'high', scripts }] : [],
|
|
52
|
-
{ id: 'held-runs-code', severity: 'high', files: holding(read => read.runsCode) },
|
|
54
|
+
{ id: 'held-runs-code', severity: 'high', files: holding(read => npmRunsCodeWithout(read.runsCode, CREDENTIAL_TAGS)) },
|
|
55
|
+
{ id: 'held-credentials', severity: 'high', files: holding(read => npmRunsCodeTagged(read.runsCode, CREDENTIAL_TAGS)) },
|
|
53
56
|
...[...HOST_TAGS].map(([tag, severity]) => ({ id: `held-${npmHostFinding(tag)}`, severity, files: holding(read => npmHostsTagged(read.exfiltration, tag)) })),
|
|
54
57
|
{ id: 'held-obfuscated', severity: 'high', files: holding(read => read.readability.obfuscation) },
|
|
55
58
|
].filter(finding => !finding.files || finding.files.length > 0)
|
|
@@ -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
|
}
|