@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.
Files changed (42) hide show
  1. package/common/managed/report-filter.ts +2 -1
  2. package/common/security-links.d.ts +5 -0
  3. package/common/security-links.js +33 -0
  4. package/out/{chunk-TY4RZD3Z.js → chunk-7RU5EDRO.js} +61 -61
  5. package/out/{chunk-XL35ZV6R.js → chunk-A7M4GV7M.js} +1 -1
  6. package/out/{chunk-2AFQKRAM.js → chunk-CLPKNNC7.js} +7 -7
  7. package/out/{chunk-UZAZM2SX.js → chunk-LAAB5SZT.js} +1 -1
  8. package/out/{chunk-KVEUXIEU.js → chunk-NWZ4PVPN.js} +1 -1
  9. package/out/{chunk-QZ3A376W.js → chunk-OSIMGFUF.js} +1 -1
  10. package/out/{chunk-U6OPEXIA.js → chunk-XOCAPDCM.js} +1 -1
  11. package/out/client-managed.js +19 -19
  12. package/out/index.html +3 -3
  13. package/out/view.js +54 -54
  14. package/package.json +9 -3
  15. package/risk/files/runs-code.js +13 -5
  16. package/risk/index.d.ts +26 -0
  17. package/risk/index.js +1 -1
  18. package/risk/npm/assess.js +60 -14
  19. package/risk/npm/combined.js +4 -1
  20. package/risk/npm/compare.js +10 -5
  21. package/risk/npm/dependencies.js +11 -3
  22. package/risk/npm/diff.js +42 -6
  23. package/risk/npm/held.js +5 -2
  24. package/risk/npm/new-dependency.js +42 -12
  25. package/risk/npm/registry.js +122 -21
  26. package/server-managed/http.ts +63 -26
  27. package/server-managed/locked-packages.ts +18 -6
  28. package/server-managed/npm-kept.ts +38 -14
  29. package/server-managed/npm-packages.ts +27 -13
  30. package/server-managed/npm-risk.ts +69 -11
  31. package/server-managed/pretty-print.ts +1 -1
  32. package/server-managed/queue.ts +23 -0
  33. package/server-managed/report-sources.ts +1 -1
  34. package/server-managed/repository-risk-assess.ts +105 -0
  35. package/server-managed/repository-risk-audit.ts +66 -0
  36. package/server-managed/repository-risk.ts +255 -201
  37. package/server-managed/scan-downloads.ts +87 -0
  38. package/server-managed/sql.ts +1 -1
  39. package/server-managed/team-reports.ts +6 -11
  40. package/server-managed/type-only-worker.js +3 -3
  41. package/server-managed/type-only.ts +4 -2
  42. 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.41",
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.0",
355
+ "@preventive/compare": "^1.2.8",
350
356
  "@preventive/diff": "^1.0.2",
351
- "@preventive/lockfile": "1.0.0",
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",
@@ -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
- // each with its lines; null for none.
586
- export function npmRunsCodeWithout(found, tags) {
587
- const kept = new Map([...found ?? []].filter(([kind]) => !tags.has(RUNS_CODE.get(kind).tag)))
588
- return kept.size > 0 ? kept : null
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'
@@ -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 = 14
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, and `created` and `gap`
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); four asked at a time, whether each is the
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
- let facts = npmAddedDependency({ downloads, published }, context.now)
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
- facts = npmAddedDependency({ downloads, listed: listed && { ...listed, private: false }, published }, context.now)
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
- const added = downloads && dependencies ? await addedDependencies(kept.dependencies.added, [was, published], context, owned) : kept.dependencies.added
205
- const assessment = npmAssessmentOf(kept, malware ? [malware, ...own] : own, added)
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. The managed
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 },
@@ -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`) marks nothing, its files held already.
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) => {
@@ -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`); a service under
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
 
@@ -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 the package a stub of DefinitelyTyped's types of it
112
- // depends on (typesStubOf), with no code to import it.
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
- if (!details?.json || kind === 'peer' || name.startsWith('@types/') || typesStubOf(details, name, to ?? range)) return false
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 file
4
- // moved to another path paired with its old one by content, as git diff -M
5
- // pairs them (@preventive/compare compareTrees).
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 moved = new Set(renames.map(change => change.new.path))
41
- for (const change of renames) changed.push({ path: change.new.path, renamedFrom: change.old.path, modified: change.similarity !== 100 })
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`), name a service under one of the hosts' tags, at the
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 }`, when its
14
- // first version was published and whether only a token reads it; and
15
- // `published`, when the version compared with and the open one were. Its
16
- // `tier` (Infinity, unpopular, where npm counts none of a public package's;
17
- // null where it can't yet be told), whether its version list is `wanted` to
18
- // tell more (below the top 20,000, or none counted), and `created`, `gap`
19
- // where its package is new since the version compared with: when its first
20
- // version was published, and how long before the open version that was
21
- // (negative for after, as a name someone took once a release named it).
22
- export function npmAddedDependency({ downloads, listed = null, published = [] }, now = Date.now()) {
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
- return { tier, wanted, created, gap: created ? Date.parse(open) - Date.parse(created) : null }
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
  }