@preventive/triage 1.0.0-alpha.40 → 1.0.0-alpha.42

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/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)
@@ -0,0 +1,59 @@
1
+ // A version's risk as one number, out of 100: what its findings call for,
2
+ // lowered by how long it has been out among how many who would have noticed.
3
+ // Its findings stay what a reviewer reads; the score orders what to read
4
+ // first.
5
+ import { DAY_MS } from './downloads.js'
6
+
7
+ // How much each finding raises the score, by its severity: a version's
8
+ // findings combined as independent chances are (1 − Π(1 − weight)), so that
9
+ // each adds less the more there are, none past 100. One red finding alone is
10
+ // 50; two, or one with two orange, past 75, as attacks show.
11
+ const WEIGHTS = { critical: 0.5, high: 0.2, moderate: 0.08, low: 0.03, notice: 0 }
12
+ // What the version's age tells instead (`too-new`), as exposure does below.
13
+ const TOLD_BY_AGE = new Set(['too-new'])
14
+
15
+ // How much a version's exposure lowers the score, at most: a version out for
16
+ // years among millions keeps a tenth of what its findings call for, as an
17
+ // attack that long among that many would have been found and pulled.
18
+ const MOST_LOWERED = 0.9
19
+ // From how old a version is lowered at all, and how old fully: two weeks, as
20
+ // risk's minimum release age (release.js), and two years, on a log scale.
21
+ const EXPOSED_FROM_DAYS = 14
22
+ const EXPOSED_DAYS = 730
23
+ // How much who downloads a package lowers it, by its tier (downloads.js): its
24
+ // rank by downloads. The top 200 fully; one few download hardly, as few would
25
+ // notice; one whose downloads aren't known (null, a caller's own package, or
26
+ // one npm counts none of) a little.
27
+ const TIER_EXPOSURE = [[200, 1], [1000, 0.95], [5000, 0.85], [20_000, 0.65], [100_000, 0.35], [Infinity, 0.1]]
28
+ const UNKNOWN_TIER_EXPOSURE = 0.25
29
+
30
+ // The score's levels, as the findings' severities name theirs.
31
+ const LEVELS = [[75, 'critical'], [50, 'high'], [25, 'moderate'], [0, 'low']]
32
+ export const npmRiskLevel = score => LEVELS.find(([from]) => score >= from)[1]
33
+
34
+ // How exposed a version has been, from 0 to 1: how long it has been out
35
+ // (`published` an ISO time, at `now`) times how widely its package is
36
+ // downloaded (`tier`, null where unknown).
37
+ export function npmExposure(published, now, tier) {
38
+ const days = typeof published === 'string' ? (now - Date.parse(published)) / DAY_MS : NaN
39
+ if (!(days > EXPOSED_FROM_DAYS)) return 0
40
+ const age = Math.min(1, Math.log(days / EXPOSED_FROM_DAYS) / Math.log(EXPOSED_DAYS / EXPOSED_FROM_DAYS))
41
+ const wide = tier === null ? UNKNOWN_TIER_EXPOSURE : TIER_EXPOSURE.find(([upTo]) => tier <= upTo)[1]
42
+ return age * wide
43
+ }
44
+
45
+ // A version's risk score (`score`, 0 to 100) and its `level`, from its
46
+ // assessment's `findings` and its exposure (npmExposure): 100 where malware
47
+ // is told of it, however old; else what its findings call for (`raised`, each
48
+ // `{ id, severity }` it counts), lowered by its exposure (`lowered`: its
49
+ // `days` out, its `tier`, and by how much, a share), none lowered under two
50
+ // weeks.
51
+ export function npmRiskScore(findings, { published = null, now = Date.now(), tier = null } = {}) {
52
+ if (findings.some(({ id }) => id === 'malware')) return { score: 100, level: 'critical', raised: [{ id: 'malware', severity: 'critical' }] }
53
+ const raised = findings.filter(({ id, severity }) => !TOLD_BY_AGE.has(id) && WEIGHTS[severity] > 0).map(({ id, severity }) => ({ id, severity }))
54
+ const called = 1 - raised.reduce((left, { severity }) => left * (1 - WEIGHTS[severity]), 1)
55
+ const exposure = npmExposure(published, now, tier)
56
+ const score = Math.round(100 * called * (1 - MOST_LOWERED * exposure))
57
+ return { score, level: npmRiskLevel(score), raised,
58
+ ...exposure > 0 ? { lowered: { days: Math.floor((now - Date.parse(published)) / DAY_MS), tier: Number.isFinite(tier) ? tier : null, by: Math.round(100 * MOST_LOWERED * exposure) / 100 } } : {} }
59
+ }
@@ -28,7 +28,7 @@ import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
28
28
  // Part of every kept copy's name; bumped when what the formatter writes for
29
29
  // the same file changes, or what its check lets through, so copies written
30
30
  // before are no longer read.
31
- const PRETTY_VERSION = 3
31
+ const PRETTY_VERSION = 4
32
32
  // Formats running at once per process, each holding its file's syntax tree,
33
33
  // and those waiting their turn past them (a version's files are formatted one
34
34
  // after another, beside other readers'), any more refused.
@@ -72,7 +72,7 @@ export interface OpenedRepository {
72
72
  recheck(): Promise<void>
73
73
  }
74
74
 
75
- type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[] }
75
+ type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
76
76
 
77
77
  export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
78
78
  repos(): Promise<R[]>
@@ -100,13 +100,14 @@ type AdvisoryRow = Advisory & { malware: boolean }
100
100
  type RiskFailure = { error: string; detail?: string }
101
101
 
102
102
  // A risk line is of npm's package of its name and version, or, with an
103
- // `integrity`, of a mirror's that its lockfiles hold to it.
103
+ // `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
104
+ // of it, out of 100 (assessNpm `score`, risk/npm/score.js).
104
105
  export type RepositoryRiskLine =
105
106
  | { type: 'repos'; repos: { id: number; fullName: string }[] }
106
107
  | ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
107
108
  | ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
108
109
  | ({ type: 'risk'; name: string; version: string; integrity?: string }
109
- & ({ published: string | null; severity: Severity | null; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
110
+ & ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
110
111
  | ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
111
112
  | { type: 'done' }
112
113
 
@@ -198,9 +199,9 @@ async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped
198
199
  try {
199
200
  const read = doc ??= await documentOf(source, pkg, signal)
200
201
  if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
201
- const { published, findings } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
202
+ const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
202
203
  const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
203
- return { ...at, published, severity: highestSeverity(told), findings: told }
204
+ return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
204
205
  } catch (error) {
205
206
  if (signal.aborted) return null
206
207
  if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
@@ -243,7 +244,8 @@ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | nu
243
244
  try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
244
245
  const key = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
245
246
  const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
246
- if (kept && Date.now() - kept.at < RISK_KEPT_MS) return kept.line
247
+ // One kept before risk told a score (assessNpm `score`) is asked again.
248
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS && ('error' in kept.line || typeof kept.line.score === 'number')) return kept.line
247
249
  const line = await assess(scan, pkg, teamScoped)
248
250
  if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
249
251
  const at = Date.now()
@@ -3,9 +3,9 @@
3
3
  // server's event loop: each pair a tree of a file a side, a side not there
4
4
  // none, its paths as they are, which say what a file is. Two paths pair as one
5
5
  // change wherever compareTrees takes them for a rename at all; where it
6
- // doesn't, the pair is types only where each of its two changes is, as each
7
- // side holds types alone and neither keeps an `export {}`, which is what it
8
- // would stamp of them paired. `[true, false, ...]` by pair.
6
+ // doesn't, the pair is types only where each of its two changes is: the file
7
+ // gone erasing whole, and the one come erasing whole or leaving a single bare
8
+ // `export {}`, which nothing loaded before. `[true, false, ...]` by pair.
9
9
  import { parentPort, workerData } from 'node:worker_threads'
10
10
  import { compareTrees } from '@preventive/compare'
11
11
 
@@ -4,8 +4,10 @@
4
4
  // paired as its viewer pairs them (rename-diff.ts comparedFiles), whose
5
5
  // change @preventive/compare's compareTrees stamps
6
6
  // `typeOnly` -- each side there a file named as a declaration file and
7
- // proven to hold types alone, so that it runs nothing (proveTypeOnly) --
8
- // and nothing else. Proven in a worker, off the event loop, a comparison at a
7
+ // proven to hold types alone but for a runtime, the module loads and
8
+ // `export {}` its stripping leaves, that the change leaves as it was, an
9
+ // added one's empty or a single bare `export {}` (proveTypeOnly) -- and
10
+ // nothing else. Proven in a worker, off the event loop, a comparison at a
9
11
  // time per process, for at most a minute; told a line each, `{ path, hash,
10
12
  // otherPath, otherHash }`, a side not there null, which the viewer checks
11
13
  // name the files it shows. Access is the caller's to check.