@preventive/triage 1.0.0-alpha.41 → 1.0.0-alpha.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/common/managed/report-filter.ts +2 -1
  2. package/common/security-links.d.ts +5 -0
  3. package/common/security-links.js +33 -0
  4. package/out/{chunk-TY4RZD3Z.js → chunk-7RU5EDRO.js} +61 -61
  5. package/out/{chunk-XL35ZV6R.js → chunk-A7M4GV7M.js} +1 -1
  6. package/out/{chunk-2AFQKRAM.js → chunk-CLPKNNC7.js} +7 -7
  7. package/out/{chunk-UZAZM2SX.js → chunk-LAAB5SZT.js} +1 -1
  8. package/out/{chunk-KVEUXIEU.js → chunk-NWZ4PVPN.js} +1 -1
  9. package/out/{chunk-QZ3A376W.js → chunk-OSIMGFUF.js} +1 -1
  10. package/out/{chunk-U6OPEXIA.js → chunk-XOCAPDCM.js} +1 -1
  11. package/out/client-managed.js +19 -19
  12. package/out/index.html +3 -3
  13. package/out/view.js +54 -54
  14. package/package.json +9 -3
  15. package/risk/files/runs-code.js +13 -5
  16. package/risk/index.d.ts +26 -0
  17. package/risk/index.js +1 -1
  18. package/risk/npm/assess.js +60 -14
  19. package/risk/npm/combined.js +4 -1
  20. package/risk/npm/compare.js +10 -5
  21. package/risk/npm/dependencies.js +11 -3
  22. package/risk/npm/diff.js +42 -6
  23. package/risk/npm/held.js +5 -2
  24. package/risk/npm/new-dependency.js +42 -12
  25. package/risk/npm/registry.js +122 -21
  26. package/server-managed/http.ts +63 -26
  27. package/server-managed/locked-packages.ts +18 -6
  28. package/server-managed/npm-kept.ts +38 -14
  29. package/server-managed/npm-packages.ts +27 -13
  30. package/server-managed/npm-risk.ts +69 -11
  31. package/server-managed/pretty-print.ts +1 -1
  32. package/server-managed/queue.ts +23 -0
  33. package/server-managed/report-sources.ts +1 -1
  34. package/server-managed/repository-risk-assess.ts +105 -0
  35. package/server-managed/repository-risk-audit.ts +66 -0
  36. package/server-managed/repository-risk.ts +255 -201
  37. package/server-managed/scan-downloads.ts +87 -0
  38. package/server-managed/sql.ts +1 -1
  39. package/server-managed/team-reports.ts +6 -11
  40. package/server-managed/type-only-worker.js +3 -3
  41. package/server-managed/type-only.ts +4 -2
  42. package/server-managed/upstream-cache.ts +16 -0
@@ -9,8 +9,13 @@
9
9
  // - `risk/npm/versions`, what a version's document says, which npm never
10
10
  // changes once published: a public package's in the database's upstream
11
11
  // cache table, shared by every instance; a private one's in memory.
12
- // - `risk/npm/packages`, `risk/npm/public`, `risk/npm/advisories` and
13
- // `risk/npm/downloads`, kept an hour or a day by risk itself: in memory.
12
+ // - `risk/npm/downloads`, a package's downloads a day, which risk tells as
13
+ // kept for a week: a public package's in the database too, so that another
14
+ // instance, or this one restarted, asks npm's downloads API, which answers
15
+ // some 40 lookups a minute, none of them again that week, each removed a
16
+ // day past it (npm-kept.ts pruneKept); a private one's in memory.
17
+ // - `risk/npm/packages`, `risk/npm/public` and `risk/npm/advisories`, kept an
18
+ // hour or a day by risk itself: in memory.
14
19
  // - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
15
20
  // read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
16
21
  // an assessment kept needs none.
@@ -28,11 +33,12 @@ import { Buffer } from 'node:buffer'
28
33
  import { createHash } from 'node:crypto'
29
34
  import { buffer } from 'node:stream/consumers'
30
35
  import type { CacheStore } from '@preventive/upstream/npm.js'
31
- import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
36
+ import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
32
37
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
33
38
  import { loadNpmTarball } from './npm-loads.ts'
34
- import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
39
+ import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
35
40
  import { npmPackageScope } from '../common/managed/npm-packages.js'
41
+ import { pruneKept } from './npm-kept.ts'
36
42
  import type { UpstreamCacheStore } from './upstream-cache.ts'
37
43
 
38
44
  const MEMORY_ENTRIES = 2000
@@ -63,6 +69,10 @@ function remember(key: string, value: unknown) {
63
69
  }
64
70
 
65
71
  const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
72
+ // What of a public package is kept in the database, and for how long its
73
+ // downloads are: risk's week, and a day more.
74
+ const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
75
+ const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
66
76
 
67
77
  export interface NpmRiskStores {
68
78
  db: UpstreamCacheStore
@@ -70,16 +80,38 @@ export interface NpmRiskStores {
70
80
  debug?: boolean | undefined
71
81
  }
72
82
 
83
+ // A cache that reads and writes many of a type at once too, as risk's
84
+ // readying of downloads asks (npmReadyDownloads): one read and one write of
85
+ // the database each, not one a key, as a request's queries take turns on its
86
+ // connection (db-neon.ts).
87
+ export interface NpmRiskCache extends CacheStore {
88
+ readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
89
+ writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
90
+ }
91
+
73
92
  // Risk's cache for one package: `isPublic` whether anyone can read it, so
74
93
  // that what is kept of it can be shared.
75
- export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): CacheStore {
94
+ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
76
95
  const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
77
- return {
96
+ const cache: NpmRiskCache = {
97
+ async readMany(type, keys) {
98
+ if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
99
+ try {
100
+ const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
101
+ return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
102
+ } catch (error) { warn('read', error); return new Map() }
103
+ },
104
+ async writeMany(type, entries) {
105
+ if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
106
+ const at = Date.now()
107
+ try { await db.setUpstreamCacheEntries(entries.map(([key, value]) => [`${type}/${key}`, JSON.stringify(value)]), at) } catch (error) { warn('write', error); return }
108
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
109
+ },
78
110
  async read(type, key) {
79
111
  if (type === 'npm/tarballs') return null
80
112
  try {
81
113
  if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
82
- if (isPublic && type === 'risk/npm/versions') { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
114
+ if (isPublic && IN_DATABASE.has(type)) { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
83
115
  } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
84
116
  return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
85
117
  },
@@ -87,11 +119,17 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
87
119
  if (type === 'npm/tarballs') return
88
120
  try {
89
121
  if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
90
- if (isPublic && type === 'risk/npm/versions') { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()); return }
122
+ if (isPublic && IN_DATABASE.has(type)) {
123
+ const at = Date.now()
124
+ await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
125
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
126
+ return
127
+ }
91
128
  } catch (error) { warn('write', error); return }
92
129
  remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
93
130
  },
94
131
  }
132
+ return cache
95
133
  }
96
134
 
97
135
  // A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
@@ -160,8 +198,9 @@ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Pr
160
198
  // by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
161
199
  // where anyone can read its package, OSV's, which alone has some of those
162
200
  // npm removed (nx's), a private package's name never sent to OSV. Its
163
- // package's document read anonymously, else with npm's token where the
164
- // reader may have it (`privileged`), and kept as a private package's is; no
201
+ // package's document read anonymously, else with npm's token alone where
202
+ // that reads more for this reader (`privileged`, npmTokenReads), and kept as
203
+ // a private package's is; no
165
204
  // tarball read, nothing of the version being left. Spare, within
166
205
  // MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
167
206
  // nothing tells of it.
@@ -171,7 +210,26 @@ export function npmRiskRemoved(name: string, version: string,
171
210
  const open = await readNpmPackument(name, false, signal)
172
211
  return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
173
212
  osv: open !== null, cache: npmRiskCache(stores, false), signal,
174
- packument: (read, { authorized }) => read === name && open ? Promise.resolve(open) : readNpmPackument(read, privileged && authorized, signal),
213
+ packument: (read, { authorized }) => read === name
214
+ ? open || !npmTokenReads(name, privileged && authorized) ? Promise.resolve(open) : readNpmPackument(name, true, signal, { anonymous: false })
215
+ : readNpmPackument(read, privileged && authorized, signal),
175
216
  tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
176
217
  })
177
218
  }
219
+
220
+ // What a scan readies before assessing its packages (repository-risk.ts):
221
+ // their downloads, as risk asks them, kept where a public package's are
222
+ // (npmRiskCache), so that their assessments wait on npm's downloads API for
223
+ // none. At once, those kept and unscoped ones, in bulk, all public, as only a
224
+ // scoped package can be private; each other one on its own
225
+ // (npmReadyScanDownload), kept only where npm counts some, which it does of
226
+ // public packages alone: a private one's none, as of one it has no such
227
+ // package of, isn't kept where public ones are, for its assessment to ask
228
+ // its own where private ones are.
229
+ export const npmReadyScanDownloads = (names: string[], ready: (name: string) => void, stores: NpmRiskStores, signal: AbortSignal) =>
230
+ npmReadyDownloads(names, ready, { cache: npmRiskCache(stores, true), signal })
231
+ export function npmReadyScanDownload(name: string, stores: NpmRiskStores, signal: AbortSignal) {
232
+ const cache = npmRiskCache(stores, true)
233
+ const counted = (value: unknown) => !(value && typeof value === 'object' && 'none' in value)
234
+ return npmReadyDownload(name, { cache: { read: cache.read, write: (type, key, value) => counted(value) ? cache.write(type, key, value) : Promise.resolve() }, signal })
235
+ }
@@ -28,7 +28,7 @@ import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
28
28
  // Part of every kept copy's name; bumped when what the formatter writes for
29
29
  // the same file changes, or what its check lets through, so copies written
30
30
  // before are no longer read.
31
- const PRETTY_VERSION = 3
31
+ const PRETTY_VERSION = 4
32
32
  // Formats running at once per process, each holding its file's syntax tree,
33
33
  // and those waiting their turn past them (a version's files are formatted one
34
34
  // after another, beside other readers'), any more refused.
@@ -0,0 +1,23 @@
1
+ // Items in the order they are added, each taken once by whichever reader asks
2
+ // first: none once closed and all taken, or once `signal` aborts, but those
3
+ // added by then where `drain`, none waited for.
4
+ export interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
5
+ export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T> {
6
+ const items: T[] = []
7
+ const waiting: (() => void)[] = []
8
+ let closed = false
9
+ const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
10
+ signal.addEventListener('abort', wake, { once: true })
11
+ return {
12
+ add(item) { items.push(item); wake() },
13
+ close() { closed = true; wake() },
14
+ async take() {
15
+ for (;;) {
16
+ if (signal.aborted && !drain) return undefined
17
+ const item = items.shift()
18
+ if (item !== undefined || closed || signal.aborted) return item
19
+ await new Promise<void>(resolve => { waiting.push(resolve) })
20
+ }
21
+ },
22
+ }
23
+ }
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
29
29
  }
30
30
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
31
31
  const key = createHash('sha256').update(JSON.stringify([
32
- 'finding-access-v9', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
+ 'finding-access-v10', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
33
33
  ])).digest('hex')
34
34
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
35
35
  }
@@ -0,0 +1,105 @@
1
+ // A package's risk line, as a repository risk scan tells it
2
+ // (repository-risk.ts): its version's document read and assessed, as risk
3
+ // tells one npm removed where npm has none; and how a failure is told.
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { NpmRiskError, SEVERITIES, type Severity } from '../risk/index.js'
6
+ import { GithubApiError } from './github-errors.ts'
7
+ import { NpmPackageError, type NpmVersionDocument } from './npm-packages.ts'
8
+ import type { RepositoryRiskLine } from './repository-risk.ts'
9
+
10
+ // An assessment refused as busy (`npm-busy`, by npm-risk.ts or the npm reads
11
+ // beneath it), or that npm asked to ask later (risk's `upstream-busy`, as its
12
+ // downloads API answers past a burst of requests), is asked again after a
13
+ // wait, up to this many times.
14
+ export const BUSY_RETRIES = 20
15
+ export const BUSY_WAIT_MS = 1000
16
+
17
+ // How long a package's risk line, or a lockfile's advisories, is told as it
18
+ // is: after it, a risk line is told stale and assessed again, as risk tells
19
+ // again what of a package changes in time, and advisories asked again, as npm
20
+ // publishes them (repository-risk.ts).
21
+ export const RISK_KEPT_MS = 60 * 60_000
22
+
23
+ // The most severe of `findings`, or null for none.
24
+ export function highestSeverity(findings: readonly { severity: Severity }[]): Severity | null {
25
+ return SEVERITIES.find(severity => findings.some(finding => finding.severity === severity)) ?? null
26
+ }
27
+
28
+ // Why a step failed, as a line says it: an error's code, and for a lockfile
29
+ // refused, what of it its reader refused.
30
+ export type RiskFailure = { error: string; detail?: string }
31
+ type RiskLine = Extract<RepositoryRiskLine, { type: 'risk' }>
32
+
33
+ // What an error is known by: a GitHub or npm failure's code, or `failed`;
34
+ // GitHub not reached with what failed (githubUnreachable), while `doing` what
35
+ // of a repository, and risk's with what it asked of npm and how that failed,
36
+ // where they say.
37
+ export function failureOf(error: unknown, doing?: string): RiskFailure {
38
+ if (error instanceof GithubApiError && error.detail) return { error: error.message, detail: doing ? `${doing}: ${error.detail}` : error.detail }
39
+ if (error instanceof GithubApiError || error instanceof NpmPackageError) return { error: error.message }
40
+ if (!(error instanceof NpmRiskError)) return { error: 'failed' }
41
+ return error.message === error.code ? { error: error.code } : { error: error.code, detail: error.message }
42
+ }
43
+
44
+ export const isBusy = (error: unknown) => (error instanceof NpmPackageError && error.status === 429) || (error instanceof NpmRiskError && error.code === 'upstream-busy')
45
+
46
+ // A package to assess: npm's of its name and version, or, with an
47
+ // `integrity`, a mirror's held to it.
48
+ export interface AssessedPackage { name: string; version: string; integrity?: string }
49
+
50
+ export type Assessment = { published: string | null; findings: { id: string; severity: Severity; takenDown?: unknown }[]; score: { score: number } }
51
+
52
+ // What of a scan's source assessing a package asks (RepositoryRiskSource).
53
+ export interface AssessingSource {
54
+ version(name: string, version: string, signal: AbortSignal): Promise<NpmVersionDocument>
55
+ assess(doc: NpmVersionDocument, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
56
+ removed(name: string, version: string, teamScoped: boolean, signal: AbortSignal): Promise<Assessment>
57
+ }
58
+
59
+ // A package's risk line, its version's document kept and the assessment asked
60
+ // again after a wait while the server is busy; null once the scan stops. A
61
+ // version npm has no document of is told as risk tells one npm removed
62
+ // (removedOf), its malware keeping whether its package was taken down.
63
+ export async function assessPackage(source: AssessingSource, signal: AbortSignal, pkg: AssessedPackage, teamScoped: boolean): Promise<RiskLine | null> {
64
+ const at = { type: 'risk', ...pkg } as const
65
+ let doc: NpmVersionDocument | NpmPackageError | undefined
66
+ for (let attempt = 0; ; attempt++) {
67
+ try {
68
+ const read = doc ??= await documentOf(source, pkg, signal)
69
+ if (!(read instanceof NpmPackageError) && pkg.integrity !== undefined && !pkg.integrity.split(/\s+/u).includes(read.dist.integrity)) return { ...at, error: 'integrity-mismatch' }
70
+ const { published, findings, score } = read instanceof NpmPackageError ? await removedOf(source, pkg, teamScoped, signal, read) : await assessedOf(source, pkg, teamScoped, signal, read)
71
+ const told = findings.map(({ id, severity, takenDown }) => ({ id, severity, ...takenDown === true ? { takenDown: true as const } : {} }))
72
+ return { ...at, published, severity: highestSeverity(told), score: score.score, findings: told }
73
+ } catch (error) {
74
+ if (signal.aborted) return null
75
+ if (!isBusy(error) || attempt >= BUSY_RETRIES) return { ...at, ...failureOf(error) }
76
+ try { await delay(BUSY_WAIT_MS * Math.min(attempt + 1, 5), undefined, { signal }) } catch { return null }
77
+ }
78
+ }
79
+ }
80
+
81
+ // npm's document of a version, or its answer that it has none (a 404).
82
+ async function documentOf(source: AssessingSource, pkg: AssessedPackage, signal: AbortSignal): Promise<NpmVersionDocument | NpmPackageError> {
83
+ try { return await source.version(pkg.name, pkg.version, signal) } catch (error) {
84
+ if (error instanceof NpmPackageError && error.status === 404) return error
85
+ throw error
86
+ }
87
+ }
88
+
89
+ // A version's assessment; where risk finds npm no longer has it, as a document
90
+ // read from where versions' are kept doesn't tell, as one npm removed.
91
+ async function assessedOf(source: AssessingSource, pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, doc: NpmVersionDocument): Promise<Assessment> {
92
+ try { return await source.assess(doc, teamScoped, signal) } catch (error) {
93
+ if (!(error instanceof NpmRiskError && error.code === 'version-not-found')) throw error
94
+ return removedOf(source, pkg, teamScoped, signal, new NpmPackageError(404, 'package-not-found'))
95
+ }
96
+ }
97
+
98
+ // A version npm has no document of, as risk tells one npm removed, as it
99
+ // removes every one it finds malware in (`removed`): `gone`, npm's answer of
100
+ // none, thrown where nothing tells of it.
101
+ async function removedOf(source: AssessingSource, pkg: AssessedPackage, teamScoped: boolean, signal: AbortSignal, gone: NpmPackageError): Promise<Assessment> {
102
+ try { return await source.removed(pkg.name, pkg.version, teamScoped, signal) } catch (error) {
103
+ throw error instanceof NpmRiskError && (error.code === 'version-not-found' || error.code === 'package-not-found') ? gone : error
104
+ }
105
+ }
@@ -0,0 +1,66 @@
1
+ // A lockfile's advisories, as a repository risk scan asks them
2
+ // (repository-risk.ts): npm's registry's of its packages of the registry or
3
+ // a mirror, asked at once, as `npm audit` asks, each told with whether it is
4
+ // of malware (risk's npmMalwareAdvisory); kept by the lockfile's blob, in the
5
+ // instance's memory and the source's `audits`, for RISK_KEPT_MS, as npm
6
+ // publishes advisories, or why they couldn't be had for AUDIT_FAILED_KEPT_MS.
7
+ import type { Advisory } from '@preventive/upstream/advisories.js'
8
+ import { isExactVersion } from '@preventive/upstream/semver.js'
9
+ import { npmMalwareAdvisory } from '../risk/index.js'
10
+ import { isNpmPackageName } from '../common/managed/npm-packages.js'
11
+ import type { LockedPackage } from './locked-packages.ts'
12
+ import type { Lru } from './lru.ts'
13
+ import type { KeptStore } from './npm-kept.ts'
14
+ import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
15
+
16
+ export type AdvisoryRow = Advisory & { malware: boolean }
17
+ // A lockfile's advisories, or why they couldn't be had.
18
+ export type Audit = AdvisoryRow[] | RiskFailure
19
+
20
+ export interface AuditSource {
21
+ // npm's registry's advisories of these packages' versions, asked at once.
22
+ advisories(packages: { name: string; versions: string[] }[], signal: AbortSignal): Promise<Advisory[]>
23
+ // Where audits are kept beyond this instance, by the lockfile's blob.
24
+ audits?: KeptStore<Audit>
25
+ }
26
+
27
+ // How long a lockfile's advisories that couldn't be had are told so, rather
28
+ // than asked again: a scan's next parts don't wait on an audit that failed,
29
+ // as by timing out, again and again.
30
+ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
31
+
32
+ // Whether an audit kept `at` is told as it is: its advisories within
33
+ // RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
34
+ const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
35
+
36
+ // The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
37
+ // the source's), else asked and kept; null where it locks none to ask of, or
38
+ // once `signal` aborts.
39
+ export async function auditLockfile(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal, key: string,
40
+ packages: readonly LockedPackage[]): Promise<Audit | null> {
41
+ let kept = memory.get(key)
42
+ if (!kept || !isFresh(kept)) {
43
+ const stored = await source.audits?.read(key)
44
+ kept = stored ? { at: stored.at, audit: stored.value } : undefined
45
+ }
46
+ if (kept && isFresh(kept)) { memory.set(key, kept); return kept.audit }
47
+ const asked = new Map<string, Set<string>>()
48
+ for (const { name, version, source: from } of packages) {
49
+ if ((from !== 'registry' && from !== 'mirror') || !isNpmPackageName(name) || !isExactVersion(version)) continue
50
+ if (!asked.has(name)) asked.set(name, new Set())
51
+ asked.get(name)!.add(version)
52
+ }
53
+ if (asked.size === 0) return null
54
+ let audit: Audit
55
+ try {
56
+ audit = (await source.advisories([...asked].map(([name, versions]) => ({ name, versions: [...versions] })), signal))
57
+ .map(advisory => ({ ...advisory, malware: npmMalwareAdvisory(advisory) }))
58
+ } catch (error) {
59
+ if (signal.aborted) return null
60
+ audit = failureOf(error)
61
+ }
62
+ const now = Date.now()
63
+ memory.set(key, { at: now, audit })
64
+ await source.audits?.write(key, audit, now)
65
+ return audit
66
+ }