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

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 (75) hide show
  1. package/common/bundle-compare-diff.js +2 -167
  2. package/common/bundle-compare-versions.js +172 -0
  3. package/common/bundle-comparison.d.ts +8 -0
  4. package/common/bundle-comparison.js +62 -0
  5. package/common/managed/report-filter.ts +2 -1
  6. package/common/managed/routes.js +3 -3
  7. package/common/security-links.d.ts +5 -0
  8. package/common/security-links.js +33 -0
  9. package/common/server-info.ts +8 -0
  10. package/common/workspace-app.js +14 -1
  11. package/out/chunk-2ABLKCOY.js +46 -0
  12. package/out/chunk-5LSXOLUS.js +1 -0
  13. package/out/chunk-5RCM2J63.js +1 -0
  14. package/out/chunk-7B6HB4D7.js +1 -0
  15. package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
  16. package/out/chunk-CH4UN6WK.js +225 -0
  17. package/out/chunk-EDKLBQQN.js +17 -0
  18. package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
  19. package/out/chunk-FK3DZEKL.js +1 -0
  20. package/out/chunk-G6AJWENO.js +1 -0
  21. package/out/chunk-GZWM7UQM.js +2 -0
  22. package/out/chunk-JCIIBLKR.js +1 -0
  23. package/out/chunk-LPCTJYNA.js +7 -0
  24. package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
  25. package/out/chunk-OD4LPN3H.js +1 -0
  26. package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
  27. package/out/chunk-QYWKC557.js +1 -0
  28. package/out/chunk-R75XOKUP.js +1 -0
  29. package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
  30. package/out/chunk-T7OKH4VW.js +1 -0
  31. package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
  32. package/out/chunk-ZESLQCTF.js +6 -0
  33. package/out/client-managed-import.js +1 -1
  34. package/out/client-managed.js +21 -21
  35. package/out/client-sync.js +1 -1
  36. package/out/graph.js +1 -1
  37. package/out/index.html +14 -10
  38. package/out/terminal.js +1 -1
  39. package/out/view.css +1 -1
  40. package/out/view.js +58 -58
  41. package/package.json +13 -3
  42. package/risk/index.d.ts +26 -0
  43. package/risk/index.js +1 -1
  44. package/risk/npm/assess.js +66 -15
  45. package/risk/npm/dependencies.js +22 -3
  46. package/risk/npm/new-dependency.js +42 -12
  47. package/risk/npm/registry.js +122 -21
  48. package/server-managed/comparison.ts +41 -0
  49. package/server-managed/http.ts +161 -56
  50. package/server-managed/locked-packages.ts +29 -6
  51. package/server-managed/npm-kept.ts +38 -14
  52. package/server-managed/npm-packages.ts +27 -13
  53. package/server-managed/npm-risk.ts +69 -11
  54. package/server-managed/pretty-print.ts +14 -9
  55. package/server-managed/public-workspace.ts +26 -8
  56. package/server-managed/queue.ts +23 -0
  57. package/server-managed/report-sources.ts +1 -1
  58. package/server-managed/repository-risk-assess.ts +105 -0
  59. package/server-managed/repository-risk-audit.ts +66 -0
  60. package/server-managed/repository-risk.ts +265 -209
  61. package/server-managed/scan-downloads.ts +87 -0
  62. package/server-managed/sql.ts +1 -1
  63. package/server-managed/team-app.ts +38 -14
  64. package/server-managed/team-reports.ts +6 -11
  65. package/server-managed/upstream-cache.ts +16 -0
  66. package/out/chunk-CLPKNNC7.js +0 -51
  67. package/out/chunk-Q7ZSIEHP.js +0 -1
  68. package/out/chunk-RFZQQXSG.js +0 -17
  69. package/out/chunk-VQJCXGIY.js +0 -7
  70. package/out/chunk-WOTMRDM3.js +0 -226
  71. package/out/chunk-WZDTZE64.js +0 -1
  72. package/out/chunk-YP2537WX.js +0 -1
  73. package/out/chunk-YQRVLRDH.js +0 -1
  74. package/out/chunk-YVZH5SHP.js +0 -1
  75. package/out/chunk-ZBBOBQ43.js +0 -1
@@ -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. A lockfile's packages of npm's registry or a
18
- // mirror are asked of npm's advisories all at once, as `npm audit` asks, each
19
- // advisory told with whether it is of malware (risk's npmMalwareAdvisory),
20
- // which alone is a supply chain's risk; the rest are vulnerabilities, told
21
- // beside.
22
- import { setTimeout as delay } from 'node:timers/promises'
23
- import type { Advisory } from '@preventive/upstream/advisories.js'
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 { isExactVersion } from '@preventive/upstream/semver.js'
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 { GithubApiError } from './github-errors.ts'
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
- import { type LockedPackage, lockedPackages } from './locked-packages.ts'
31
+ import { type LockedPackage, isTestFixture, 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
- type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
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
- // What an error is known by: a GitHub or npm failure's code, or `failed`;
115
- // GitHub not reached with what failed (githubUnreachable), while `doing` what
116
- // of a repository, and risk's with what it asked of npm and how that failed,
117
- // where they say.
118
- function failureOf(error: unknown, doing?: string): RiskFailure {
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; and a package's risk line and a lockfile's
133
- // advisories, asked again after RISK_KEPT_MS, as risk tells again what of a
134
- // package changes in time (a deprecation, its age, its downloads) and npm
135
- // publishes advisories. Each is told only once access to its repository is
136
- // checked as when it was read.
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; rows: AdvisoryRow[] }>
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.rows.length + 1),
144
+ advisories: lru(50_000, kept => Array.isArray(kept.audit) ? kept.audit.length + 1 : 1),
149
145
  })
150
- export const RISK_KEPT_MS = 60 * 60_000
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
- // Packages to assess, and those already asked for, by name, version and a
186
- // mirror's integrity.
187
- pending: Queue<AssessedPackage>
188
- seen: Set<string>
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's risk line, its version's document kept and the assessment asked
192
- // again after a wait while the server is busy; null once the scan stops. A
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
- // npm's document of a version, or its answer that it has none (a 404).
214
- async function documentOf(source: Scan['source'], pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
215
- try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
216
- if (error instanceof NpmPackageError && error.status === 404) return error
217
- throw error
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 version's assessment; where risk finds npm no longer has it, as a document
222
- // read from where versions' are kept doesn't tell, as one npm removed.
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 = `${pkg.name}@${pkg.version}\0${pkg.integrity ?? ''}\0${teamScoped}`
246
- const kept = scan.caches.risks.get(key) ?? await keptLine(scan, key)
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
- async function keptLine({ source, caches }: Scan, key: string) {
259
- const hit = await source.risks?.read(key)
260
- if (!hit) return null
261
- caches.risks.set(key, { at: hit.at, line: hit.value })
262
- return { at: hit.at, line: hit.value }
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 scan.pending.take(); next !== undefined; next = await scan.pending.take()) {
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
- const key = `${format}\0${sha}`
280
- let read = caches.lockfiles.get(key)
281
- if (!read) {
282
- let text
283
- try { text = await opened.file(path) } catch (error) { return { ...at, ...failureOf(error) } }
284
- try { read = { packages: lockedPackages(format, text) } } catch (error) {
285
- read = { error: 'lockfile-unreadable', ...error instanceof Error ? { detail: error.message.slice(0, 500) } : {} }
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
- // given to the assessors.
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
- for (const { name, version, source, integrity } of packages) {
296
- const key = `${name}@${version}\0${integrity ?? ''}`
297
- if ((source !== 'registry' && source !== 'mirror') || version === null || scan.seen.has(key) || !isNpmPackageName(name) || !isNpmPackageSpec(version)) continue
298
- scan.seen.add(key)
299
- scan.pending.add({ name, version, ...integrity === undefined ? {} : { integrity } })
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
- if (!listed) scan.caches.listings.set(key, listed = await opened.lockfiles())
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,88 +323,129 @@ 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: npm's registry's advisories of its packages
326
- // of the registry or a mirror, by its blob as kept, else asked at once and
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
- const key = `${format}\0${sha}`
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
351
335
  // then its lockfiles', and their advisories: told once they are all read
352
336
  // and access to the repository is checked again, as GitHub may have
353
- // withdrawn it meanwhile.
337
+ // withdrawn it meanwhile. A test's fixture or example its reader refuses
338
+ // (isTestFixture) isn't told at all, as a test may keep one so on purpose.
354
339
  async function readRepository(scan: Scan, repo: RepositoryRiskRepo) {
355
340
  const listed = await listLockfiles(scan, repo)
356
341
  if (!listed) return
357
- const lockfiles: LockfileLine[] = []
342
+ const lockfiles: { sha: string; line: LockfileLine }[] = []
358
343
  for (const lockfile of listed.lockfiles) {
359
344
  if (scan.signal.aborted) return
360
- lockfiles.push(await readLockfile(scan, repo.repoId, listed.opened, lockfile))
345
+ const line = await readLockfile(scan, repo.repoId, listed.opened, lockfile)
346
+ if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
361
347
  }
362
348
  try { await listed.opened.recheck() } catch (error) {
363
349
  if (!scan.signal.aborted) scan.lines.add({ type: 'repo', id: repo.repoId, ...failureOf(error, 'checking access to it again') })
364
350
  return
365
351
  }
366
352
  if (scan.signal.aborted) return
367
- scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: listed.lockfiles.map(({ path, format }) => ({ path, format })) })
368
- for (const line of lockfiles) {
353
+ scan.lines.add({ type: 'repo', id: repo.repoId, commit: listed.opened.commit, lockfiles: lockfiles.map(({ line: { path, format } }) => ({ path, format })) })
354
+ for (const { line } of lockfiles) {
369
355
  scan.lines.add(line)
370
356
  if ('packages' in line) assessLater(scan, line.packages)
371
357
  }
372
- for (const [at, line] of lockfiles.entries()) {
358
+ for (const { sha, line } of lockfiles) {
373
359
  if (scan.signal.aborted) return
374
- const advisories = 'packages' in line ? await readAdvisories(scan, line, listed.lockfiles[at]!.sha) : null
360
+ const advisories = 'packages' in line ? await readAdvisories(scan, line, sha) : null
375
361
  if (advisories) scan.lines.add(advisories)
376
362
  }
363
+ if (scan.signal.aborted) return
364
+ scan.told.set(repo.repoId, { id: repo.repoId, name: repo.fullName, paths: repo.paths ?? null,
365
+ lockfiles: lockfiles.flatMap(({ sha, line }) => 'packages' in line ? [`${line.format}\0${sha}`] : []) })
377
366
  }
378
367
 
379
- // Each repository, a repository at a time.
368
+ // Whether an earlier part told `repo` whole, by its name and a team's
369
+ // directories in it as they are now, its packages then looked up as its
370
+ // lockfiles are kept, GitHub asked nothing of it: not where one isn't. It is
371
+ // told as read then, its default branch's head not asked again.
372
+ async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
373
+ const told = scan.told.get(repo.repoId)
374
+ if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
375
+ const reads: LockedPackage[][] = []
376
+ for (const key of told.lockfiles) {
377
+ const read = scan.caches.lockfiles.get(key) ?? (await scan.source.lockfiles?.read(key))?.value
378
+ if (!read || !('packages' in read)) return false
379
+ reads.push(read.packages)
380
+ }
381
+ for (const packages of reads) assessLater(scan, packages)
382
+ return true
383
+ }
384
+
385
+ // Each repository, a repository at a time, but those an earlier part told
386
+ // whole (ScanProgress).
380
387
  async function readRepositories(scan: Scan) {
381
388
  const repos = await scan.source.repos()
382
389
  scan.lines.add({ type: 'repos', repos: repos.map(repo => ({ id: repo.repoId, fullName: repo.fullName })) })
390
+ // Those still listed: one no longer is read again should it be once more.
391
+ const listed = new Set(repos.map(repo => repo.repoId))
392
+ // Those within SCAN_READ_KEPT_MS, as their lockfiles are kept that long.
393
+ const kept = await scan.source.progress?.read()
394
+ const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
395
+ for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
383
396
  for (const repo of repos) {
384
397
  if (scan.signal.aborted) return
385
- await readRepository(scan, repo)
398
+ if (!await toldBefore(scan, repo)) await readRepository(scan, repo)
386
399
  }
387
400
  }
388
401
 
402
+ // What is left to assess once the repositories are read, a turn at a time:
403
+ // the stale ones that more than devDependencies need, then those only
404
+ // devDependencies need, those with none kept before the stale ones.
405
+ function leftOver(scan: Scan): AssessedPackage[][] {
406
+ const dev = (pkg: AssessedPackage) => scan.seen.get(seenKey(pkg)) === true
407
+ return [scan.stale.filter(pkg => !dev(pkg)), [...scan.dev.values(), ...scan.stale.filter(dev)]]
408
+ }
409
+
389
410
  // The scan's lines, in the order they are found: the repositories first,
390
411
  // then each repository's lockfiles and their packages once read and access
391
- // is checked again, and each package's risk once assessed, while the
392
- // repositories are still being read; `done` last. Ends at once, the work
412
+ // is checked again, and each package's risk: as kept, however old, at once
413
+ // (lookUp), then those with none kept once assessed, while the repositories
414
+ // are still being read, then those told stale once assessed again, and those
415
+ // only devDependencies need after all the rest (leftOver); `done`
416
+ // last. Ends at once, the work
393
417
  // under way given up on, where `signal` aborts or the lines are no longer read. A failure of
394
418
  // a repository, a lockfile or a package is told in its line; one of the scan
395
419
  // itself is thrown, the lines before it told. What it reads is kept in
396
- // `caches` (RiskCaches), the instance's own unless given.
397
- export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared): AsyncGenerator<RepositoryRiskLine> {
398
- const stopped = new AbortController()
399
- const running = AbortSignal.any([signal, stopped.signal])
400
- const scan: Scan = { source, caches, signal: running, lines: queue(signal), pending: queue(running), seen: new Set() }
420
+ // `caches` (RiskCaches), the instance's own unless given. Given `partMs`, it
421
+ // stops once that long, as a function time-limited must (Vercel's), the work
422
+ // under way given up on, its last line `continue`, for its reader to ask
423
+ // again: those it assessed are kept, and told at once then, and the
424
+ // repositories it told whole kept as its source's `progress`.
425
+ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source: RepositoryRiskSource<R>, signal: AbortSignal, caches = shared,
426
+ { partMs = null }: { partMs?: number | null } = {}): AsyncGenerator<RepositoryRiskLine> {
427
+ const part = new AbortController(), stopped = new AbortController()
428
+ const parting = partMs === null ? undefined : setTimeout(() => { part.abort() }, partMs)
429
+ const running = AbortSignal.any([signal, stopped.signal, part.signal])
430
+ // The lines told until the part's time is up, those found by then told, and
431
+ // none waited for: the work under way, such as a request to GitHub, which
432
+ // isn't given up on at once, outlasting it.
433
+ const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
434
+ const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map() }
401
435
  const failed: unknown[] = []
402
436
  void (async () => {
403
- const assessors = Array.from({ length: MAX_SPARE_ASSESSMENTS }, () => assessor(scan))
404
437
  try {
405
- try { await readRepositories(scan) } finally { scan.pending.close() }
406
- await Promise.all(assessors)
438
+ // Those with no risk line kept assessed first, as their lockfiles are read,
439
+ await assessing(scan, async assess => {
440
+ const looking = lookUp(scan, assess)
441
+ // stopped at once where reading fails;
442
+ try { await readRepositories(scan) } catch (error) { failed.push(error); stopped.abort() } finally { scan.lookups.close() }
443
+ await looking
444
+ })
445
+ // then those told stale, and those only devDependencies need, in turn.
446
+ for (const left of leftOver(scan)) {
447
+ if (!running.aborted && left.length > 0) await assessing(scan, assess => { assess(left); return Promise.resolve() })
448
+ }
407
449
  if (!running.aborted) scan.lines.add({ type: 'done' })
408
450
  } catch (error) {
409
451
  failed.push(error)
@@ -411,10 +453,24 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
411
453
  } finally { scan.lines.close() }
412
454
  })()
413
455
  try {
414
- for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) yield line
415
- if (failed.length > 0 && !signal.aborted) throw failed[0]
456
+ let done = false
457
+ for (let line = await scan.lines.take(); line !== undefined; line = await scan.lines.take()) {
458
+ done ||= line.type === 'done'
459
+ yield line
460
+ }
461
+ if (done || signal.aborted) return
462
+ if (part.signal.aborted) {
463
+ // Not waited on past PROGRESS_WRITE_MS, within what Vercel's limit
464
+ // leaves past the part's: the next part then goes on from less.
465
+ let timer
466
+ await Promise.race([source.progress?.write({ repos: [...scan.told.values()] }), new Promise(resolve => { timer = setTimeout(resolve, PROGRESS_WRITE_MS) })])
467
+ clearTimeout(timer)
468
+ yield { type: 'continue' }
469
+ }
470
+ else if (failed.length > 0) throw failed[0]
416
471
  } finally {
417
472
  // A GitHub request under way isn't waited on: it settles on its own.
473
+ clearTimeout(parting)
418
474
  stopped.abort()
419
475
  }
420
476
  }