@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/out/{chunk-XYDGU6BM.js → chunk-CLPKNNC7.js} +14 -14
- package/out/{chunk-UN5ZSYLE.js → chunk-D7AF2JXL.js} +1 -1
- package/out/{chunk-B2RQY6OP.js → chunk-DGVQPIYB.js} +1 -1
- package/out/{chunk-IISEF2BZ.js → chunk-Q7ZSIEHP.js} +1 -1
- package/out/{chunk-MYDDTA7W.js → chunk-WOTMRDM3.js} +60 -59
- package/out/{chunk-TGANXVTA.js → chunk-YP2537WX.js} +1 -1
- package/out/{chunk-AQ75QITE.js → chunk-YQRVLRDH.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +3 -3
- package/out/view.css +1 -1
- package/out/view.js +3 -3
- package/package.json +3 -2
- package/risk/files/runs-code.js +269 -24
- package/risk/index.d.ts +14 -0
- package/risk/npm/assess.js +9 -5
- package/risk/npm/combined.js +4 -1
- package/risk/npm/compare.js +10 -5
- package/risk/npm/diff.js +42 -6
- package/risk/npm/held.js +5 -2
- package/risk/npm/score.js +59 -0
- package/server-managed/pretty-print.ts +1 -1
- package/server-managed/repository-risk.ts +8 -6
- package/server-managed/type-only-worker.js +3 -3
- package/server-managed/type-only.ts +4 -2
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)
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
8
|
-
//
|
|
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.
|