@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/managed/report-filter.ts +2 -1
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/out/{chunk-WOTMRDM3.js → chunk-7RU5EDRO.js} +61 -61
- package/out/{chunk-D7AF2JXL.js → chunk-A7M4GV7M.js} +1 -1
- package/out/{chunk-DGVQPIYB.js → chunk-LAAB5SZT.js} +1 -1
- package/out/{chunk-YQRVLRDH.js → chunk-NWZ4PVPN.js} +1 -1
- package/out/{chunk-YP2537WX.js → chunk-OSIMGFUF.js} +1 -1
- package/out/{chunk-Q7ZSIEHP.js → chunk-XOCAPDCM.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +2 -2
- package/out/view.js +54 -54
- package/package.json +9 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +60 -14
- package/risk/npm/dependencies.js +11 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/http.ts +63 -26
- package/server-managed/locked-packages.ts +18 -6
- package/server-managed/npm-kept.ts +38 -14
- package/server-managed/npm-packages.ts +27 -13
- package/server-managed/npm-risk.ts +69 -11
- package/server-managed/queue.ts +23 -0
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +66 -0
- package/server-managed/repository-risk.ts +255 -201
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
|
@@ -14,41 +14,33 @@
|
|
|
14
14
|
// from elsewhere (git, a tarball with no integrity, a file) is listed, not
|
|
15
15
|
// assessed. Repositories are read one at a time, as GitHub's limits are the
|
|
16
16
|
// caller's; packages are assessed meanwhile, as many at a time as npm-risk.ts
|
|
17
|
-
// makes spare assessments
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
// makes spare assessments, those only devDependencies need in every lockfile
|
|
18
|
+
// that locks them (LockedPackage `dev`) once all the rest are, as an install
|
|
19
|
+
// of no devDependencies leaves them out. A lockfile's packages of npm's
|
|
20
|
+
// registry or a mirror are asked of npm's advisories all at once, as `npm
|
|
21
|
+
// audit` asks, each advisory told with whether it is of malware (risk's
|
|
22
|
+
// npmMalwareAdvisory), which alone is a supply chain's risk; the rest are
|
|
23
|
+
// vulnerabilities, told beside.
|
|
24
24
|
import type { LockfileFormat, RepoLockfile } from '@preventive/upstream/github.js'
|
|
25
|
-
import {
|
|
26
|
-
import { NpmRiskError, SEVERITIES, type Severity, npmMalwareAdvisory } from '../risk/index.js'
|
|
25
|
+
import type { Severity } from '../risk/index.js'
|
|
27
26
|
import { isNpmPackageName, isNpmPackageSpec } from '../common/managed/npm-packages.js'
|
|
28
|
-
import {
|
|
29
|
-
import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
|
|
27
|
+
import type { NpmVersionDocument } from './npm-packages.ts'
|
|
30
28
|
import { type Lru, lru } from './lru.ts'
|
|
31
29
|
import type { KeptStore } from './npm-kept.ts'
|
|
32
30
|
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
33
31
|
import { type LockedPackage, lockedPackages } from './locked-packages.ts'
|
|
32
|
+
import { type Queue, queue } from './queue.ts'
|
|
33
|
+
import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
|
|
34
|
+
import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
|
|
35
|
+
import { type AdvisoryRow, type Audit, type AuditSource, auditLockfile } from './repository-risk-audit.ts'
|
|
36
|
+
|
|
37
|
+
export { RISK_KEPT_MS, highestSeverity } from './repository-risk-assess.ts'
|
|
34
38
|
|
|
35
39
|
// The formats of npm's ecosystem, as findRepoLockfiles names them, and those
|
|
36
40
|
// of them read here.
|
|
37
41
|
const NPM_LOCKFILE_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno'])
|
|
38
42
|
const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'])
|
|
39
43
|
|
|
40
|
-
// An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
|
|
41
|
-
// beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
|
|
42
|
-
// downloads API answers past a burst of requests), is asked again after a
|
|
43
|
-
// wait, up to this many times.
|
|
44
|
-
const BUSY_RETRIES = 20
|
|
45
|
-
const BUSY_WAIT_MS = 1000
|
|
46
|
-
|
|
47
|
-
// The most severe of `findings`, or null for none.
|
|
48
|
-
export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
|
|
49
|
-
return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
44
|
// A repository to read: where `paths` are given, a team's directories in it,
|
|
53
45
|
// only the lockfiles in them or above them, as a workspace's root lockfile
|
|
54
46
|
// locks them too.
|
|
@@ -72,9 +64,7 @@ export interface OpenedRepository {
|
|
|
72
64
|
recheck(): Promise<void>
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
|
|
67
|
+
export interface RepositoryRiskSource<R extends RepositoryRiskRepo> extends AuditSource {
|
|
78
68
|
repos(): Promise<R[]>
|
|
79
69
|
open(repo: R): Promise<OpenedRepository>
|
|
80
70
|
// Whether a team lists a package's scope, its own packages.
|
|
@@ -86,18 +76,27 @@ export interface RepositoryRiskSource<R extends RepositoryRiskRepo> {
|
|
|
86
76
|
// malware alone, its package taken down by npm; an NpmRiskError where
|
|
87
77
|
// nothing tells of it (npm-risk.ts npmRiskRemoved).
|
|
88
78
|
removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
|
|
89
|
-
// npm's registry's advisories of these packages' versions, asked at once.
|
|
90
|
-
advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
|
|
91
79
|
// Where risk lines are kept beyond this instance, as RiskCaches keeps them
|
|
92
80
|
// in it (npm-kept.ts riskLineStore, the database's).
|
|
93
81
|
risks?: KeptStore<RiskLine>
|
|
82
|
+
// Where a repository's lockfiles as listed at a commit, and a lockfile's
|
|
83
|
+
// packages by its blob, are kept beyond this instance, as RiskCaches keeps
|
|
84
|
+
// them in it (npm-kept.ts scanReadStore, the database's), each read for
|
|
85
|
+
// SCAN_READ_KEPT_MS: a scan answered in parts (`partMs`) goes on from
|
|
86
|
+
// where the last stopped on whichever instance answers the next.
|
|
87
|
+
listings?: KeptStore<ListedLockfile[]>
|
|
88
|
+
lockfiles?: KeptStore<LockfileRead>
|
|
89
|
+
// Where a scan answered in parts keeps, from one part to the next, the
|
|
90
|
+
// repositories it told whole (`partMs`): the next tells none of them again,
|
|
91
|
+
// nor asks GitHub of them, but looks up their packages' risks as their
|
|
92
|
+
// lockfiles are kept (`lockfiles`), as the reader has the rest already.
|
|
93
|
+
progress?: { read(): Promise<{ at: number; value: ScanProgress } | null>; write(progress: ScanProgress): Promise<void> } | undefined
|
|
94
|
+
// Readies npm's downloads of packages before they are assessed
|
|
95
|
+
// (scan-downloads.ts); without it, each assessment asks its own.
|
|
96
|
+
downloads?: DownloadsSource
|
|
94
97
|
}
|
|
95
98
|
|
|
96
|
-
type AdvisoryRow = Advisory & { malware: boolean }
|
|
97
99
|
|
|
98
|
-
// Why a step failed, as a line says it: an error's code, and for a lockfile
|
|
99
|
-
// refused, what of it its reader refused.
|
|
100
|
-
type RiskFailure = { error: string; detail?: string }
|
|
101
100
|
|
|
102
101
|
// A risk line is of npm's package of its name and version, or, with an
|
|
103
102
|
// `integrity`, of a mirror's that its lockfiles hold to it; its `score` risk's
|
|
@@ -106,147 +105,88 @@ export type RepositoryRiskLine =
|
|
|
106
105
|
| { type: 'repos'; repos: { id: number; fullName: string }[] }
|
|
107
106
|
| ({ type: 'repo'; id: number } & ({ commit: string; lockfiles: { path: string; format: LockfileFormat }[] } | RiskFailure))
|
|
108
107
|
| ({ type: 'lockfile'; repo: number; path: string; format: LockfileFormat } & ({ packages: LockedPackage[] } | RiskFailure))
|
|
109
|
-
| ({ type: 'risk'; name: string; version: string; integrity?: string }
|
|
108
|
+
| ({ type: 'risk'; name: string; version: string; integrity?: string; stale?: { assessedAt: number } }
|
|
110
109
|
& ({ published: string | null; severity: Severity | null; score: number; findings: { id: string; severity: Severity; takenDown?: true }[] } | RiskFailure))
|
|
111
110
|
| ({ type: 'advisories'; repo: number; path: string } & ({ advisories: AdvisoryRow[] } | RiskFailure))
|
|
112
111
|
| { type: 'done' }
|
|
112
|
+
// The scan stopped for its part's time (repositoryRiskLines `partMs`), what
|
|
113
|
+
// it told kept: to be asked again, from where it stopped.
|
|
114
|
+
| { type: 'continue' }
|
|
113
115
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
|
|
120
|
-
if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
|
|
121
|
-
if (!(error instanceof NpmRiskError)) return { error: 'failed' }
|
|
122
|
-
return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
|
|
116
|
+
// The repositories a scan answered in parts told whole by a part's end: each
|
|
117
|
+
// by its id, its name and a team's directories in it then, and its
|
|
118
|
+
// lockfiles' keys (`${format}\0${sha}`) of those read.
|
|
119
|
+
export interface ScanProgress { repos: ToldRepository[] }
|
|
120
|
+
interface ToldRepository { id: number; name: string; paths: readonly string[] | null; lockfiles: string[] }
|
|
126
121
|
|
|
127
122
|
type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
|
|
128
123
|
type LockfileRead = { packages: LockedPackage[] } | RiskFailure
|
|
129
124
|
|
|
130
125
|
// What scans keep between them, in this instance's memory: a repository's
|
|
131
126
|
// lockfiles as listed at a commit, and a lockfile's packages by its blob,
|
|
132
|
-
// neither of which changes;
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
127
|
+
// neither of which changes; a lockfile's advisories, asked again after
|
|
128
|
+
// RISK_KEPT_MS, as npm publishes advisories; and a package's risk line,
|
|
129
|
+
// told stale after RISK_KEPT_MS and assessed again, as risk tells again what
|
|
130
|
+
// of a package changes in time (a deprecation, its age, its downloads). A
|
|
131
|
+
// lockfile's, and its packages' lines, are told only once access to its
|
|
132
|
+
// repository is checked as when it was read.
|
|
137
133
|
export interface RiskCaches {
|
|
138
134
|
listings: Lru<ListedLockfile[]>
|
|
139
135
|
lockfiles: Lru<LockfileRead>
|
|
140
136
|
risks: Lru<{ at: number; line: RiskLine }>
|
|
141
|
-
advisories: Lru<{ at: number;
|
|
137
|
+
advisories: Lru<{ at: number; audit: Audit }>
|
|
142
138
|
}
|
|
143
139
|
export const riskCaches = (): RiskCaches => ({
|
|
144
140
|
listings: lru(1000),
|
|
145
141
|
// By the packages they hold, a few hundred bytes each.
|
|
146
142
|
lockfiles: lru(200_000, read => 'packages' in read ? read.packages.length + 1 : 1),
|
|
147
143
|
risks: lru(50_000),
|
|
148
|
-
advisories: lru(50_000, kept => kept.
|
|
144
|
+
advisories: lru(50_000, kept => Array.isArray(kept.audit) ? kept.audit.length + 1 : 1),
|
|
149
145
|
})
|
|
150
|
-
|
|
146
|
+
// How long the source's `listings` and `lockfiles` are read for: what
|
|
147
|
+
// GitHub has at a commit or a blob doesn't change, how it is read here may.
|
|
148
|
+
export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
|
|
149
|
+
// How long a part's progress, written as it ends (`progress`), is waited on.
|
|
150
|
+
const PROGRESS_WRITE_MS = 5000
|
|
151
|
+
// How many kept risk lines are read of the source's `risks` at once.
|
|
152
|
+
const KEPT_READ = 500
|
|
151
153
|
const shared = riskCaches()
|
|
152
154
|
|
|
153
|
-
// Items in the order they are added, each taken once by whichever reader asks
|
|
154
|
-
// first: none once closed and all taken, or once `signal` aborts.
|
|
155
|
-
interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
|
|
156
|
-
function queue<T>(signal: AbortSignal): Queue<T> {
|
|
157
|
-
const items: T[] = []
|
|
158
|
-
const waiting: (() => void)[] = []
|
|
159
|
-
let closed = false
|
|
160
|
-
const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
|
|
161
|
-
signal.addEventListener('abort', wake, { once: true })
|
|
162
|
-
return {
|
|
163
|
-
add(item) { items.push(item); wake() },
|
|
164
|
-
close() { closed = true; wake() },
|
|
165
|
-
async take() {
|
|
166
|
-
for (;;) {
|
|
167
|
-
if (signal.aborted) return undefined
|
|
168
|
-
const item = items.shift()
|
|
169
|
-
if (item !== undefined || closed) return item
|
|
170
|
-
await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
171
|
-
}
|
|
172
|
-
},
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// A package to assess: npm's of its name and version, or, with an
|
|
177
|
-
// `integrity`, a mirror's held to it.
|
|
178
|
-
interface AssessedPackage { name: string; version: string; integrity?: string }
|
|
179
|
-
|
|
180
155
|
interface Scan {
|
|
181
156
|
source: RepositoryRiskSource<RepositoryRiskRepo>
|
|
182
157
|
caches: RiskCaches
|
|
183
158
|
signal: AbortSignal
|
|
184
159
|
lines: Queue<RepositoryRiskLine>
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
160
|
+
// Each lockfile's packages not yet seen, to look up as kept (lookUp); those
|
|
161
|
+
// already seen, by seenKey, each with whether only devDependencies need it
|
|
162
|
+
// so far; those of them with none kept, put off as long as that holds, by
|
|
163
|
+
// seenKey; and those told stale, to assess again once those with none kept
|
|
164
|
+
// are.
|
|
165
|
+
lookups: Queue<AssessedPackage[]>
|
|
166
|
+
seen: Map<string, boolean>
|
|
167
|
+
dev: Map<string, AssessedPackage>
|
|
168
|
+
stale: AssessedPackage[]
|
|
169
|
+
// The repositories told whole, this part or one before (ScanProgress).
|
|
170
|
+
told: Map<number, ToldRepository>
|
|
189
171
|
}
|
|
190
172
|
|
|
191
|
-
// A package
|
|
192
|
-
|
|
193
|
-
// version npm has no document of is told as risk tells one npm removed
|
|
194
|
-
// (removedOf), its malware keeping whether its package was taken down.
|
|
195
|
-
async function assess({ source, signal }: Scan, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
|
|
196
|
-
const at = { type: 'risk', ...pkg } as const
|
|
197
|
-
let doc: NpmVersionDocument | NpmPackageError | undefined
|
|
198
|
-
for (let attempt = 0; ; attempt++) {
|
|
199
|
-
try {
|
|
200
|
-
const read = doc ??= await documentOf(source, pkg, signal)
|
|
201
|
-
if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
|
|
202
|
-
const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
|
|
203
|
-
const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
|
|
204
|
-
return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (signal.aborted) return null
|
|
207
|
-
if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
|
|
208
|
-
try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
173
|
+
// A package as a scan meets it: by its name, version and a mirror's integrity.
|
|
174
|
+
const seenKey = (pkg: AssessedPackage) => `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}`
|
|
212
175
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
176
|
+
// A package's key, as its risk line is kept: its name, version and a
|
|
177
|
+
// mirror's integrity, and whether a team lists its scope.
|
|
178
|
+
const riskKey = (pkg: AssessedPackage, teamScoped: boolean) => `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
|
|
179
|
+
// Whether a line kept `at` is told as it is: within RISK_KEPT_MS, and kept
|
|
180
|
+
// since risk told a score (assessNpm `score`).
|
|
181
|
+
const isFresh = ({ at, line }: { at: number; line: RiskLine }) => Date.now() - at < RISK_KEPT_MS && ('error' in line || typeof line.score === 'number')
|
|
220
182
|
|
|
221
|
-
// A
|
|
222
|
-
//
|
|
223
|
-
async function assessedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
|
|
224
|
-
try { return await source.assess(doc, teamScoped, signal) } catch (error) {
|
|
225
|
-
if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
|
|
226
|
-
return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// A version npm has no document of, as risk tells one npm removed, as it
|
|
231
|
-
// removes every one it finds malware in (`removed`): `gone`, npm's answer of
|
|
232
|
-
// none, thrown where nothing tells of it.
|
|
233
|
-
async function removedOf(source: Scan['source'], pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
|
|
234
|
-
try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
|
|
235
|
-
throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// A package's risk line as kept (RiskCaches, else the source's `risks`), else
|
|
240
|
-
// assessed and kept, in both, where it tells what risk found, or that a
|
|
241
|
-
// mirror's copy isn't npm's.
|
|
183
|
+
// A package assessed (assess), its line kept, in RiskCaches and the source's
|
|
184
|
+
// `risks`, where it tells what risk found, or that a mirror's copy isn't npm's.
|
|
242
185
|
async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | null> {
|
|
243
186
|
let teamScoped
|
|
244
187
|
try { teamScoped = await scan.source.teamScoped(pkg.name) } catch (error) { return { type: 'risk', ...pkg, ...failureOf(error) } }
|
|
245
|
-
const key =
|
|
246
|
-
const
|
|
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
|
|
249
|
-
const line = await assess(scan, pkg, teamScoped)
|
|
188
|
+
const key = riskKey(pkg, teamScoped)
|
|
189
|
+
const line = await assessPackage(scan.source, scan.signal, pkg, teamScoped)
|
|
250
190
|
if (line && (!('error' in line) || line.error === 'integrity-mismatch')) {
|
|
251
191
|
const at = Date.now()
|
|
252
192
|
scan.caches.risks.set(key, { at, line })
|
|
@@ -255,49 +195,112 @@ async function assessed(scan: Scan, pkg: AssessedPackage): Promise<RiskLine | nu
|
|
|
255
195
|
return line
|
|
256
196
|
}
|
|
257
197
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
198
|
+
// Risk lines kept of `keys`, by key: in this instance's memory, else the
|
|
199
|
+
// source's `risks`, KEPT_READ at a time.
|
|
200
|
+
async function keptLines({ source, caches }: Scan, keys: string[]) {
|
|
201
|
+
const kept = new Map<string, { at: number; line: RiskLine }>()
|
|
202
|
+
const left = keys.filter(key => { const hit = caches.risks.get(key); if (hit) kept.set(key, hit); return !hit })
|
|
203
|
+
for (let from = 0; source.risks && from < left.length; from += KEPT_READ) {
|
|
204
|
+
for (const [key, { at, value }] of await source.risks.readMany(left.slice(from, from + KEPT_READ))) {
|
|
205
|
+
caches.risks.set(key, { at, line: value })
|
|
206
|
+
kept.set(key, { at, line: value })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return kept
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Each lockfile's packages, as assessLater gives them: their risk lines as
|
|
213
|
+
// kept, however old, told at once, a fresh one as it is and an older one as
|
|
214
|
+
// stale (`stale`, when it was assessed), its package assessed again once
|
|
215
|
+
// those with none kept are (`scan.stale`); those with none given to `assess`,
|
|
216
|
+
// but those only devDependencies need so far, put off (`scan.dev`).
|
|
217
|
+
async function lookUp(scan: Scan, assess: (packages: AssessedPackage[]) => void) {
|
|
218
|
+
for (let packages = await scan.lookups.take(); packages !== undefined; packages = await scan.lookups.take()) {
|
|
219
|
+
const keyed: { pkg: AssessedPackage; key: string }[] = []
|
|
220
|
+
for (const pkg of packages) {
|
|
221
|
+
try { keyed.push({ pkg, key: riskKey(pkg, await scan.source.teamScoped(pkg.name)) }) } catch (error) { scan.lines.add({ type: 'risk', ...pkg, ...failureOf(error) }) }
|
|
222
|
+
}
|
|
223
|
+
const kept = await keptLines(scan, keyed.map(({ key }) => key))
|
|
224
|
+
const missing: AssessedPackage[] = []
|
|
225
|
+
for (const { pkg, key } of keyed) {
|
|
226
|
+
const hit = kept.get(key)
|
|
227
|
+
if (hit && isFresh(hit)) scan.lines.add(hit.line)
|
|
228
|
+
else if (hit) { scan.lines.add({ ...hit.line, stale: { assessedAt: hit.at } }); scan.stale.push(pkg) }
|
|
229
|
+
else if (scan.seen.get(seenKey(pkg)) === true) scan.dev.set(seenKey(pkg), pkg)
|
|
230
|
+
else missing.push(pkg)
|
|
231
|
+
}
|
|
232
|
+
if (missing.length > 0) assess(missing)
|
|
233
|
+
}
|
|
263
234
|
}
|
|
264
235
|
|
|
265
|
-
async function assessor(scan: Scan) {
|
|
266
|
-
for (let next = await
|
|
236
|
+
async function assessor(scan: Scan, pending: Queue<AssessedPackage>) {
|
|
237
|
+
for (let next = await pending.take(); next !== undefined; next = await pending.take()) {
|
|
267
238
|
const line = await assessed(scan, next)
|
|
268
239
|
if (line) scan.lines.add(line)
|
|
269
240
|
}
|
|
270
241
|
}
|
|
271
242
|
|
|
243
|
+
// The packages `feed` gives assessed, each given to the assessors once its
|
|
244
|
+
// downloads are readied, where the source readies them (scan-downloads.ts):
|
|
245
|
+
// once all it gives are told, or the scan stops.
|
|
246
|
+
async function assessing(scan: Scan, feed: (assess: (packages: AssessedPackage[]) => void) => Promise<void>) {
|
|
247
|
+
const pending = queue<AssessedPackage>(scan.signal)
|
|
248
|
+
const readying = scan.source.downloads
|
|
249
|
+
? readyingDownloads<AssessedPackage>(scan.source.downloads, scan.signal, pkg => { pending.add(pkg) }, { busy: isBusy, retries: BUSY_RETRIES, waitMs: BUSY_WAIT_MS }) : null
|
|
250
|
+
const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan, pending))
|
|
251
|
+
try { await feed(packages => { if (readying) readying.add(packages); else for (const pkg of packages) pending.add(pkg) }) } finally {
|
|
252
|
+
await readying?.close()
|
|
253
|
+
pending.close()
|
|
254
|
+
}
|
|
255
|
+
await Promise.all(assessors)
|
|
256
|
+
}
|
|
257
|
+
|
|
272
258
|
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
273
259
|
|
|
260
|
+
// What is kept by `key`: in this instance's memory, else in `store` within
|
|
261
|
+
// SCAN_READ_KEPT_MS, else `read` and kept in both.
|
|
262
|
+
async function keptRead<T>(memory: Lru<T>, store: KeptStore<T> | undefined, key: string, read: () => Promise<T>) {
|
|
263
|
+
const known = memory.get(key)
|
|
264
|
+
if (known !== undefined) return known
|
|
265
|
+
const kept = await store?.read(key)
|
|
266
|
+
if (kept && Date.now() - kept.at < SCAN_READ_KEPT_MS) { memory.set(key, kept.value); return kept.value }
|
|
267
|
+
const value = await read()
|
|
268
|
+
memory.set(key, value)
|
|
269
|
+
await store?.write(key, value, Date.now())
|
|
270
|
+
return value
|
|
271
|
+
}
|
|
272
|
+
|
|
274
273
|
// A lockfile's line, its packages or why they couldn't be had: as kept by its
|
|
275
274
|
// blob, else read from GitHub and kept.
|
|
276
|
-
async function readLockfile({ caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
|
|
275
|
+
async function readLockfile({ source, caches }: Scan, repo: number, opened: OpenedRepository, { path, format, sha }: ListedLockfile): Promise<LockfileLine> {
|
|
277
276
|
const at = { type: 'lockfile', repo, path, format } as const
|
|
278
277
|
if (!READ_FORMATS.has(format)) return { ...at, error: 'unsupported-format' }
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
caches.lockfiles.set(key, read)
|
|
288
|
-
}
|
|
289
|
-
return { ...at, ...read }
|
|
278
|
+
try {
|
|
279
|
+
return { ...at, ...await keptRead(caches.lockfiles, source.lockfiles, `${format}\0${sha}`, async (): Promise<LockfileRead> => {
|
|
280
|
+
const text = await opened.file(path)
|
|
281
|
+
try { return { packages: lockedPackages(format, text) } } catch (error) {
|
|
282
|
+
return { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
|
|
283
|
+
}
|
|
284
|
+
}) }
|
|
285
|
+
} catch (error) { return { ...at, ...failureOf(error) } }
|
|
290
286
|
}
|
|
291
287
|
|
|
292
288
|
// A lockfile's packages of npm's registry or a mirror not yet seen this scan,
|
|
293
|
-
//
|
|
289
|
+
// to look up as kept (lookUp), and assess where none is; and one seen as only
|
|
290
|
+
// devDependencies need it, and put off, that this lockfile needs otherwise,
|
|
291
|
+
// to look up again and assess now.
|
|
294
292
|
function assessLater(scan: Scan, packages: LockedPackage[]) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if ((source !== 'registry' && source !== 'mirror') || version === null ||
|
|
298
|
-
|
|
299
|
-
|
|
293
|
+
const fresh: AssessedPackage[] = []
|
|
294
|
+
for (const { name, version, source, integrity, dev = false } of packages) {
|
|
295
|
+
if ((source !== 'registry' && source !== 'mirror') || version === null || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
|
|
296
|
+
const pkg = { name, version, ...integrity === undefined ? {} : { integrity } }
|
|
297
|
+
const key = seenKey(pkg)
|
|
298
|
+
const was = scan.seen.get(key)
|
|
299
|
+
if (was === false || (was && dev)) continue
|
|
300
|
+
scan.seen.set(key, dev)
|
|
301
|
+
if (was === undefined || scan.dev.delete(key)) fresh.push(pkg)
|
|
300
302
|
}
|
|
303
|
+
if (fresh.length > 0) scan.lookups.add(fresh)
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
// A repository opened and its lockfiles of npm's ecosystem listed, as kept
|
|
@@ -308,10 +311,8 @@ async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
|
|
|
308
311
|
try {
|
|
309
312
|
const opened = await scan.source.open(repo)
|
|
310
313
|
if (scan.signal.aborted) return null
|
|
311
|
-
const key = `${repo.repoId}@${opened.commit}`
|
|
312
|
-
let listed = scan.caches.listings.get(key)
|
|
313
314
|
doing = 'listing its lockfiles'
|
|
314
|
-
|
|
315
|
+
const listed = await keptRead(scan.caches.listings, scan.source.listings, `${repo.repoId}@${opened.commit}`, () => opened.lockfiles())
|
|
315
316
|
const lockfiles = listed.filter(({ path, format }) => NPM_LOCKFILE_FORMATS.has(format) && lockfileOf(path, repo.paths))
|
|
316
317
|
return { opened, lockfiles }
|
|
317
318
|
} catch (error) {
|
|
@@ -322,29 +323,12 @@ async function listLockfiles(scan: Scan, repo: RepositoryRiskRepo) {
|
|
|
322
323
|
|
|
323
324
|
type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
|
|
324
325
|
|
|
325
|
-
// A lockfile's advisories line
|
|
326
|
-
//
|
|
327
|
-
// kept; null where it locks none, or once the scan stops.
|
|
326
|
+
// A lockfile's advisories line (repository-risk-audit.ts auditLockfile);
|
|
327
|
+
// null where it locks none, or once the scan stops.
|
|
328
328
|
async function readAdvisories(scan: Scan, { repo, path, format, packages }: LockfileLine & { packages: LockedPackage[] }, sha: string): Promise<AdvisoriesLine | null> {
|
|
329
|
+
const audit = await auditLockfile(scan.source, scan.caches.advisories, scan.signal, `${format}\0${sha}`, packages)
|
|
329
330
|
const at = { type: 'advisories', repo, path } as const
|
|
330
|
-
|
|
331
|
-
const kept = scan.caches.advisories.get(key)
|
|
332
|
-
if (kept && Date.now() - kept.at < RISK_KEPT_MS) return { ...at, advisories: kept.rows }
|
|
333
|
-
const asked = new Map<string, Set<string>>()
|
|
334
|
-
for (const { name, version, source } of packages) {
|
|
335
|
-
if ((source !== 'registry' && source !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
|
|
336
|
-
if (!asked.has(name)) asked.set(name, new Set())
|
|
337
|
-
asked.get(name)!.add(version)
|
|
338
|
-
}
|
|
339
|
-
if (asked.size === 0) return null
|
|
340
|
-
try {
|
|
341
|
-
const rows = (await scan.source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), scan.signal))
|
|
342
|
-
.map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
|
|
343
|
-
scan.caches.advisories.set(key, { at: Date.now(), rows })
|
|
344
|
-
return { ...at, advisories: rows }
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return scan.signal.aborted ? null : { ...at, ...failureOf(error) }
|
|
347
|
-
}
|
|
331
|
+
return audit === null ? null : Array.isArray(audit) ? { ...at, advisories: audit } : { ...at, ...audit }
|
|
348
332
|
}
|
|
349
333
|
|
|
350
334
|
// A repository's line, its lockfiles or why they couldn't be listed, and
|
|
@@ -374,36 +358,92 @@ async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
|
|
|
374
358
|
const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
|
|
375
359
|
if (advisories) scan.lines.add(advisories)
|
|
376
360
|
}
|
|
361
|
+
if (scan.signal.aborted) return
|
|
362
|
+
scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
|
|
363
|
+
lockfiles: lockfiles.flatMap((line, at) => 'packages' in line ? [`${line.format}\0${listed.lockfiles[at]!.sha}`] : []) })
|
|
377
364
|
}
|
|
378
365
|
|
|
379
|
-
//
|
|
366
|
+
// Whether an earlier part told `repo` whole, by its name and a team's
|
|
367
|
+
// directories in it as they are now, its packages then looked up as its
|
|
368
|
+
// lockfiles are kept, GitHub asked nothing of it: not where one isn't. It is
|
|
369
|
+
// told as read then, its default branch's head not asked again.
|
|
370
|
+
async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
|
|
371
|
+
const told = scan.told.get(repo.repoId)
|
|
372
|
+
if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
|
|
373
|
+
const reads: LockedPackage[][] = []
|
|
374
|
+
for (const key of told.lockfiles) {
|
|
375
|
+
const read = scan.caches.lockfiles.get(key) ?? (await scan.source.lockfiles?.read(key))?.value
|
|
376
|
+
if (!read || !('packages' in read)) return false
|
|
377
|
+
reads.push(read.packages)
|
|
378
|
+
}
|
|
379
|
+
for (const packages of reads) assessLater(scan, packages)
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Each repository, a repository at a time, but those an earlier part told
|
|
384
|
+
// whole (ScanProgress).
|
|
380
385
|
async function readRepositories(scan: Scan) {
|
|
381
386
|
const repos = await scan.source.repos()
|
|
382
387
|
scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
|
|
388
|
+
// Those still listed: one no longer is read again should it be once more.
|
|
389
|
+
const listed = new Set(repos.map(repo => repo.repoId))
|
|
390
|
+
// Those within SCAN_READ_KEPT_MS, as their lockfiles are kept that long.
|
|
391
|
+
const kept = await scan.source.progress?.read()
|
|
392
|
+
const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
|
|
393
|
+
for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
|
|
383
394
|
for (const repo of repos) {
|
|
384
395
|
if (scan.signal.aborted) return
|
|
385
|
-
await readRepository(scan, repo)
|
|
396
|
+
if (!await toldBefore(scan, repo)) await readRepository(scan, repo)
|
|
386
397
|
}
|
|
387
398
|
}
|
|
388
399
|
|
|
400
|
+
// What is left to assess once the repositories are read, a turn at a time:
|
|
401
|
+
// the stale ones that more than devDependencies need, then those only
|
|
402
|
+
// devDependencies need, those with none kept before the stale ones.
|
|
403
|
+
function leftOver(scan: Scan): AssessedPackage[][] {
|
|
404
|
+
const dev = (pkg: AssessedPackage) => scan.seen.get(seenKey(pkg)) === true
|
|
405
|
+
return [scan.stale.filter(pkg => !dev(pkg)), [...scan.dev.values(), ...scan.stale.filter(dev)]]
|
|
406
|
+
}
|
|
407
|
+
|
|
389
408
|
// The scan's lines, in the order they are found: the repositories first,
|
|
390
409
|
// then each repository's lockfiles and their packages once read and access
|
|
391
|
-
// is checked again, and each package's risk
|
|
392
|
-
//
|
|
410
|
+
// is checked again, and each package's risk: as kept, however old, at once
|
|
411
|
+
// (lookUp), then those with none kept once assessed, while the repositories
|
|
412
|
+
// are still being read, then those told stale once assessed again, and those
|
|
413
|
+
// only devDependencies need after all the rest (leftOver); `done`
|
|
414
|
+
// last. Ends at once, the work
|
|
393
415
|
// under way given up on, where `signal` aborts or the lines are no longer read. A failure of
|
|
394
416
|
// a repository, a lockfile or a package is told in its line; one of the scan
|
|
395
417
|
// itself is thrown, the lines before it told. What it reads is kept in
|
|
396
|
-
// `caches` (RiskCaches), the instance's own unless given.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
418
|
+
// `caches` (RiskCaches), the instance's own unless given. Given `partMs`, it
|
|
419
|
+
// stops once that long, as a function time-limited must (Vercel's), the work
|
|
420
|
+
// under way given up on, its last line `continue`, for its reader to ask
|
|
421
|
+
// again: those it assessed are kept, and told at once then, and the
|
|
422
|
+
// repositories it told whole kept as its source's `progress`.
|
|
423
|
+
export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared,
|
|
424
|
+
{ partMs = null }: { partMs?: number | null } = {}): AsyncGenerator<RepositoryRiskLine> {
|
|
425
|
+
const part = new AbortController(), stopped = new AbortController()
|
|
426
|
+
const parting = partMs === null ? undefined : setTimeout(() => { part.abort() }, partMs)
|
|
427
|
+
const running = AbortSignal.any([signal, stopped.signal, part.signal])
|
|
428
|
+
// The lines told until the part's time is up, those found by then told, and
|
|
429
|
+
// none waited for: the work under way, such as a request to GitHub, which
|
|
430
|
+
// isn't given up on at once, outlasting it.
|
|
431
|
+
const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
|
|
432
|
+
const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map() }
|
|
401
433
|
const failed: unknown[] = []
|
|
402
434
|
void (async () => {
|
|
403
|
-
const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
|
|
404
435
|
try {
|
|
405
|
-
|
|
406
|
-
await
|
|
436
|
+
// Those with no risk line kept assessed first, as their lockfiles are read,
|
|
437
|
+
await assessing(scan, async assess => {
|
|
438
|
+
const looking = lookUp(scan, assess)
|
|
439
|
+
// stopped at once where reading fails;
|
|
440
|
+
try { await readRepositories(scan) } catch (error) { failed.push(error); stopped.abort() } finally { scan.lookups.close() }
|
|
441
|
+
await looking
|
|
442
|
+
})
|
|
443
|
+
// then those told stale, and those only devDependencies need, in turn.
|
|
444
|
+
for (const left of leftOver(scan)) {
|
|
445
|
+
if (!running.aborted && left.length > 0) await assessing(scan, assess => { assess(left); return Promise.resolve() })
|
|
446
|
+
}
|
|
407
447
|
if (!running.aborted) scan.lines.add({ type: 'done' })
|
|
408
448
|
} catch (error) {
|
|
409
449
|
failed.push(error)
|
|
@@ -411,10 +451,24 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
|
|
|
411
451
|
} finally { scan.lines.close() }
|
|
412
452
|
})()
|
|
413
453
|
try {
|
|
414
|
-
|
|
415
|
-
|
|
454
|
+
let done = false
|
|
455
|
+
for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) {
|
|
456
|
+
done ||= line.type === 'done'
|
|
457
|
+
yield line
|
|
458
|
+
}
|
|
459
|
+
if (done || signal.aborted) return
|
|
460
|
+
if (part.signal.aborted) {
|
|
461
|
+
// Not waited on past PROGRESS_WRITE_MS, within what Vercel's limit
|
|
462
|
+
// leaves past the part's: the next part then goes on from less.
|
|
463
|
+
let timer
|
|
464
|
+
await Promise.race([source.progress?.write({ repos: [...scan.told.values()] }), new Promise(resolve => { timer = setTimeout(resolve, PROGRESS_WRITE_MS) })])
|
|
465
|
+
clearTimeout(timer)
|
|
466
|
+
yield { type: 'continue' }
|
|
467
|
+
}
|
|
468
|
+
else if (failed.length > 0) throw failed[0]
|
|
416
469
|
} finally {
|
|
417
470
|
// A GitHub request under way isn't waited on: it settles on its own.
|
|
471
|
+
clearTimeout(parting)
|
|
418
472
|
stopped.abort()
|
|
419
473
|
}
|
|
420
474
|
}
|