@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46
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/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- package/out/chunk-ZESLQCTF.js +0 -6
package/server-managed/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { initializeApp } from '../server-common/initialize.ts'
|
|
|
11
11
|
import { type ManagedConfig, loadManagedConfig } from './config.ts'
|
|
12
12
|
import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
|
|
13
13
|
import { setNpmTarballCache } from './npm-loads.ts'
|
|
14
|
+
import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
|
|
14
15
|
import { setNpmDocumentCache } from './npm-packages.ts'
|
|
15
16
|
import { loadManagedStatic } from './static.ts'
|
|
16
17
|
import { openManagedStorage } from './storage.ts'
|
|
@@ -21,7 +22,8 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
|
|
|
21
22
|
const REAP_INTERVAL_MS = 3_600_000
|
|
22
23
|
const REAP_RETRY_MS = 60_000
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// `ui: false` serves only the API: `out/` is never read.
|
|
26
|
+
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
|
|
25
27
|
|
|
26
28
|
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
27
29
|
// Audits keep upstream's records on disk where the config has a cache
|
|
@@ -34,10 +36,13 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
|
|
|
34
36
|
// Load the optional peer only for serverless managed deployments. Embedding
|
|
35
37
|
// hosts can discard listener promises, so retain maintenance inside the app.
|
|
36
38
|
const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
|
|
37
|
-
|
|
39
|
+
// The host's reviewed dependencies, registered before; none after.
|
|
40
|
+
const reviewedDependencies = assembledReviewedDependencies()
|
|
41
|
+
const assembled = { ...options, ...reviewedDependencies ? { reviewedDependencies } : {} }
|
|
42
|
+
return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
45
|
+
async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
41
46
|
const storage = await openManagedStorage(config)
|
|
42
47
|
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
|
|
43
48
|
rollback.defer(() => db.close())
|
|
@@ -51,12 +56,12 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
51
56
|
p.finally(() => inFlight.delete(p)).catch(() => {})
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
59
|
+
const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
|
|
55
60
|
indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
|
|
56
|
-
})
|
|
61
|
+
}) : () => false
|
|
57
62
|
const routeRequest = createManagedRequestHandler({
|
|
58
63
|
...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
|
|
59
|
-
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
|
|
64
|
+
...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
|
|
60
65
|
isShuttingDown, track,
|
|
61
66
|
})
|
|
62
67
|
|
|
@@ -133,8 +138,8 @@ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'm
|
|
|
133
138
|
].join('\n'))
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
export async function init(config = loadManagedConfig()): Promise<Server> {
|
|
137
|
-
const app = await createManagedApp(config)
|
|
141
|
+
export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
|
|
142
|
+
const app = await createManagedApp(config, options)
|
|
138
143
|
const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
|
|
139
144
|
let disposal: Promise<void> | undefined
|
|
140
145
|
function dispose(): Promise<void> {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What was lately worked out, kept a little while in memory for whoever asks
|
|
2
|
+
// for it next, by key: each for `keptMs` from when it was kept, the least
|
|
3
|
+
// lately read going first past `maxEntries` of them or `maxBytes` between
|
|
4
|
+
// them, and one that alone takes more than `maxBytes` not kept at all.
|
|
5
|
+
// Whoever reads one is the caller's to check, as they would one worked out.
|
|
6
|
+
export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
|
|
7
|
+
const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
|
|
8
|
+
let heldBytes = 0
|
|
9
|
+
const forget = (key: string) => {
|
|
10
|
+
const entry = held.get(key)
|
|
11
|
+
if (!entry) return
|
|
12
|
+
clearTimeout(entry.timer)
|
|
13
|
+
held.delete(key)
|
|
14
|
+
heldBytes -= entry.bytes
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
get(key: string): T | undefined {
|
|
18
|
+
const entry = held.get(key)
|
|
19
|
+
if (!entry) return undefined
|
|
20
|
+
held.delete(key)
|
|
21
|
+
held.set(key, entry)
|
|
22
|
+
return entry.value
|
|
23
|
+
},
|
|
24
|
+
keep(key: string, value: T, bytes: number) {
|
|
25
|
+
if (bytes > maxBytes) return
|
|
26
|
+
forget(key)
|
|
27
|
+
for (const old of held.keys()) {
|
|
28
|
+
if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
|
|
29
|
+
forget(old)
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(() => { forget(key) }, keptMs)
|
|
32
|
+
timer.unref()
|
|
33
|
+
held.set(key, { value, bytes, timer })
|
|
34
|
+
heldBytes += bytes
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -7,6 +7,7 @@ import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
|
|
|
7
7
|
import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
|
|
8
8
|
import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
|
|
9
9
|
import type { LockfileFormat } from '@preventive/upstream/github.js'
|
|
10
|
+
import type { ReviewedLockfile } from './reviewed-dependencies.ts'
|
|
10
11
|
|
|
11
12
|
export interface LockedPackage {
|
|
12
13
|
name: string
|
|
@@ -19,9 +20,16 @@ export interface LockedPackage {
|
|
|
19
20
|
// Where one from elsewhere than npm's registry comes from, without any
|
|
20
21
|
// credentials its URL has.
|
|
21
22
|
from?: string
|
|
22
|
-
//
|
|
23
|
-
// and version only where npm's integrity is among it
|
|
23
|
+
// As the lockfile has it: a mirror's tarball's, npm's package of its name
|
|
24
|
+
// and version only where npm's integrity is among it; and, where its
|
|
25
|
+
// reviews ask (`review`), npm's registry's too.
|
|
24
26
|
integrity?: string
|
|
27
|
+
// Where its reviews ask: the sha1 Yarn 1 writes after a tarball's `#`, in
|
|
28
|
+
// hex (`shasum`); the hash of the patch pnpm applies to it
|
|
29
|
+
// (`patchHash`, its patchedDependencies'); and the lockfile's format.
|
|
30
|
+
shasum?: string
|
|
31
|
+
patchHash?: string
|
|
32
|
+
lockfile?: ReviewedLockfile
|
|
25
33
|
// Where only the importers' devDependencies reach it, as npm's `dev` flag
|
|
26
34
|
// says, and an install of no devDependencies leaves it out: in npm's and
|
|
27
35
|
// pnpm's lockfiles. Yarn 1's says nothing of it, which only the
|
|
@@ -32,10 +40,10 @@ export interface LockedPackage {
|
|
|
32
40
|
// A package as each reader resolves one, its name the one it is published
|
|
33
41
|
// under, and a registry tarball's URL held to its name and version.
|
|
34
42
|
type Resolution =
|
|
35
|
-
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
|
|
43
|
+
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; sha1?: string | undefined; gitHosted?: boolean }
|
|
36
44
|
| { type: 'git'; repo: string }
|
|
37
45
|
| { type: 'directory' }
|
|
38
|
-
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
|
|
46
|
+
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined; patchHash?: string | undefined }
|
|
39
47
|
|
|
40
48
|
// The packages of a lockfile of `format`, as its reader resolves them.
|
|
41
49
|
// Versions go unchecked against ranges, as only what is locked is asked for.
|
|
@@ -54,41 +62,54 @@ function packagesOf(format: LockfileFormat, text: string): ReadPackage[] {
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
// npm's registry, by either of its names, and yarn's mirror of it, as
|
|
57
|
-
// @preventive/lockfile holds their tarballs to a package's name and version
|
|
65
|
+
// @preventive/lockfile holds their tarballs to a package's name and version:
|
|
66
|
+
// its link to one, as it names it, a hash after it alone (Yarn 1's sha1).
|
|
58
67
|
const REGISTRY_HOSTS = new Set(['registry.npmjs.org', 'registry.npmjs.com', 'registry.yarnpkg.com'])
|
|
59
|
-
const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password
|
|
68
|
+
const isRegistry = (url: URL) => url.protocol === 'https:' && !url.port && !url.username && !url.password && !url.search
|
|
60
69
|
&& REGISTRY_HOSTS.has(url.hostname.replace(/\.+$/u, ''))
|
|
61
70
|
// Where a URL is, as it may be shown: its credentials, query and fragment
|
|
62
71
|
// left out.
|
|
63
72
|
const shown = (url: URL | null) => url ? { from: `${url.protocol}//${url.host}${url.pathname}` } : {}
|
|
64
73
|
|
|
65
|
-
// Where a package read comes from (LockedPackage)
|
|
66
|
-
//
|
|
67
|
-
|
|
74
|
+
// Where a package read comes from (LockedPackage), with npm's registry's
|
|
75
|
+
// integrity where `review`; null for one a package bundles, with no
|
|
76
|
+
// resolution, or one of the project's own directories.
|
|
77
|
+
function located({ name, version, resolution }: ReadPackage, review: boolean): LockedPackage | null {
|
|
68
78
|
const at = { name, version: version ?? null }
|
|
69
79
|
if (resolution === undefined || resolution.type === 'directory') return null
|
|
70
80
|
if (resolution.type === 'git') return { ...at, source: 'git', ...shown(URL.parse(resolution.repo)) }
|
|
71
81
|
const { tarball, integrity } = resolution
|
|
72
82
|
if (resolution.gitHosted) return { ...at, source: 'git', ...shown(URL.parse(tarball ?? '')) }
|
|
73
|
-
|
|
83
|
+
const registry = { ...at, source: 'registry' as const, ...review && integrity ? { integrity } : {} }
|
|
84
|
+
if (tarball === undefined) return registry
|
|
74
85
|
if (tarball.startsWith('file:')) return { ...at, source: 'file' }
|
|
75
86
|
const url = URL.parse(tarball)
|
|
76
|
-
if (url && isRegistry(url)) return
|
|
87
|
+
if (url && isRegistry(url)) return registry
|
|
77
88
|
return integrity ? { ...at, ...shown(url), source: 'mirror', integrity } : { ...at, ...shown(url), source: 'tarball' }
|
|
78
89
|
}
|
|
90
|
+
// As above, and with what its reviews ask (LockedPackage `shasum`,
|
|
91
|
+
// `patchHash`, `lockfile`) where `review`.
|
|
92
|
+
function locked(pkg: ReadPackage, format: LockfileFormat, review: boolean): LockedPackage | null {
|
|
93
|
+
const one = located(pkg, review)
|
|
94
|
+
if (!one || !review) return one
|
|
95
|
+
const sha1 = pkg.resolution?.type === 'tarball' ? pkg.resolution.sha1 : undefined
|
|
96
|
+
return { ...one, ...sha1 ? { shasum: sha1 } : {}, ...pkg.patchHash ? { patchHash: pkg.patchHash } : {}, lockfile: format === 'yarn' ? 'yarn1' : format as ReviewedLockfile }
|
|
97
|
+
}
|
|
79
98
|
|
|
80
99
|
// The packages a lockfile of `format` (npm, pnpm or yarn, as
|
|
81
100
|
// findRepoLockfiles names them) locks, each once, in the lockfile's order:
|
|
82
101
|
// `dev` only where it is wherever it is installed, at each path of npm's
|
|
83
|
-
// tree, and with each of pnpm's peers
|
|
84
|
-
//
|
|
85
|
-
|
|
102
|
+
// tree, and with each of pnpm's peers; with `review`, each with what its
|
|
103
|
+
// reviews hold it to (LockedPackage `integrity`, `shasum`, `patchHash`),
|
|
104
|
+
// each patch of it apart. Throws the reader's LockfileError or YamlError for
|
|
105
|
+
// one it refuses.
|
|
106
|
+
export function lockedPackages(format: LockfileFormat, text: string, { review = false } = {}): LockedPackage[] {
|
|
86
107
|
const found = new Map<string, LockedPackage>()
|
|
87
108
|
for (const pkg of packagesOf(format, text)) {
|
|
88
|
-
const one = locked(pkg)
|
|
109
|
+
const one = locked(pkg, format, review)
|
|
89
110
|
if (!one) continue
|
|
90
111
|
if (pkg.dev === true) one.dev = true
|
|
91
|
-
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
|
|
112
|
+
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}\0${one.shasum ?? ''}\0${one.patchHash ?? ''}`
|
|
92
113
|
const had = found.get(key)
|
|
93
114
|
if (!had) found.set(key, one)
|
|
94
115
|
else if (had.dev && !one.dev) delete had.dev
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The lockfiles a repository's dependency review holds to, as an admin sets
|
|
2
|
+
// it (repository-dependency-reviews.ts): every one but those it names
|
|
3
|
+
// (`exclude`, naming none for all of them), or those it names alone
|
|
4
|
+
// (`include`). Each is named by its path in the repository, or, ending in
|
|
5
|
+
// `/`, by a directory, every lockfile under it. A directory of a pull
|
|
6
|
+
// request's lockfiles (pull-dependencies.ts) is reviewed where one of its
|
|
7
|
+
// lockfiles is held to; one none of whose are, not at all (`excluded`), nor
|
|
8
|
+
// failing the review where it couldn't be read.
|
|
9
|
+
export interface LockfileScope { mode: 'exclude' | 'include'; paths: string[] }
|
|
10
|
+
export const EVERY_LOCKFILE: LockfileScope = { mode: 'exclude', paths: [] }
|
|
11
|
+
export const MAX_SCOPED_LOCKFILES = 100
|
|
12
|
+
const MAX_PATH = 300
|
|
13
|
+
|
|
14
|
+
// A path as one is named: relative, of `/`-separated names none of which is
|
|
15
|
+
// empty, `.` or `..`, nor holding a backslash or a control character; a
|
|
16
|
+
// directory's ending in `/`.
|
|
17
|
+
function namedPath(path: unknown): path is string {
|
|
18
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH || path.startsWith('/')) return false
|
|
19
|
+
if (/[\\\p{Cc}]/u.test(path)) return false
|
|
20
|
+
const names = path.endsWith('/') ? path.slice(0, -1).split('/') : path.split('/')
|
|
21
|
+
return names.every(name => name !== '' && name !== '.' && name !== '..')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A scope as a request sends it, each path once, in order; null for one
|
|
25
|
+
// that isn't one.
|
|
26
|
+
export function scopeOf(value: unknown): LockfileScope | null {
|
|
27
|
+
const { mode, paths } = (value ?? {}) as { mode?: unknown; paths?: unknown }
|
|
28
|
+
if ((mode !== 'exclude' && mode !== 'include') || !Array.isArray(paths) || paths.length > MAX_SCOPED_LOCKFILES || !paths.every(namedPath)) return null
|
|
29
|
+
return { mode, paths: [...new Set(paths as string[])].toSorted() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A scope as stored (JSON, none for every lockfile), every lockfile where
|
|
33
|
+
// it can't be read.
|
|
34
|
+
export function storedScope(text: string | null): LockfileScope {
|
|
35
|
+
if (!text) return EVERY_LOCKFILE
|
|
36
|
+
try { return scopeOf(JSON.parse(text)) ?? EVERY_LOCKFILE } catch { return EVERY_LOCKFILE }
|
|
37
|
+
}
|
|
38
|
+
export const scopeText = (scope: LockfileScope) => isEvery(scope) ? null : JSON.stringify({ mode: scope.mode, paths: scope.paths })
|
|
39
|
+
export const isEvery = (scope: LockfileScope) => scope.mode === 'exclude' && scope.paths.length === 0
|
|
40
|
+
|
|
41
|
+
const names = (path: string, named: string) => named.endsWith('/') ? path.startsWith(named) : path === named
|
|
42
|
+
// Whether the lockfile at `path` is held to.
|
|
43
|
+
export function heldTo(scope: LockfileScope, path: string) {
|
|
44
|
+
const named = scope.paths.some(one => names(path, one))
|
|
45
|
+
return scope.mode === 'include' ? named : !named
|
|
46
|
+
}
|
|
47
|
+
// Whether a directory of lockfiles at `paths` is reviewed: where one is
|
|
48
|
+
// held to, or it names none.
|
|
49
|
+
export const reviewedIn = (scope: LockfileScope, paths: string[]) => paths.length === 0 || paths.some(path => heldTo(scope, path))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The lines a package's files change from one side to another (npm-delta.ts),
|
|
2
|
+
// as @preventive/compare's compareTrees counts them (git's --numstat), off
|
|
3
|
+
// the server's event loop: of its text files, renames found as git finds
|
|
4
|
+
// them, a side not there empty; and, apart, those of its changes it stamps
|
|
5
|
+
// `typeOnly`, declaration files changed in types alone.
|
|
6
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
7
|
+
import { compareTrees } from '@preventive/compare'
|
|
8
|
+
|
|
9
|
+
const tree = files => files.map(([name, data]) => ({ name, data }))
|
|
10
|
+
const changes = compareTrees(tree(workerData.base), tree(workerData.other), { paths: 'keys', lines: true, renames: true })
|
|
11
|
+
const answer = { additions: 0, deletions: 0, typeAdditions: 0, typeDeletions: 0, typeFiles: 0 }
|
|
12
|
+
for (const change of changes) {
|
|
13
|
+
if (change.binary) continue
|
|
14
|
+
answer.additions += change.insertions ?? 0
|
|
15
|
+
answer.deletions += change.deletions ?? 0
|
|
16
|
+
if (change.typeOnly !== true) continue
|
|
17
|
+
answer.typeAdditions += change.insertions ?? 0
|
|
18
|
+
answer.typeDeletions += change.deletions ?? 0
|
|
19
|
+
answer.typeFiles++
|
|
20
|
+
}
|
|
21
|
+
// Node worker messages have no browser target origin.
|
|
22
|
+
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
23
|
+
parentPort.postMessage(answer)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The lines a version of a package changes against another, or against none,
|
|
2
|
+
// its whole package's (GET /api/npm/delta), as a list of versions wanting
|
|
3
|
+
// reviews tells them beside each: added and removed (`additions`,
|
|
4
|
+
// `deletions`), as git's --numstat counts them, of their text files, a
|
|
5
|
+
// renamed one diffed with what it was; and, where some of the files changed
|
|
6
|
+
// are declaration files @preventive/compare's compareTrees proves changed in
|
|
7
|
+
// types alone (`typeOnly`, proveTypeOnly: types and comments alone erased,
|
|
8
|
+
// the runtime they leave as it was, an added one's empty or a single bare
|
|
9
|
+
// `export {}`), those of the rest (`typeless`), what a reviewer may pass by
|
|
10
|
+
// leaving those out; and whether a binary file, which they count none of,
|
|
11
|
+
// was added, removed or changed (`binary`), one moved as it was not. Worked
|
|
12
|
+
// out in a worker, off the event loop, as many at once per process as
|
|
13
|
+
// MAX_ACTIVE, both sides' files read together only once their turn has come,
|
|
14
|
+
// those waiting past MAX_WAITING refused, each waiting a minute at most and
|
|
15
|
+
// counting in a minute at most; one asked again meanwhile sharing the
|
|
16
|
+
// first's; and kept by both sides' integrities, in this instance and beyond
|
|
17
|
+
// it (`kept`), what a version holds never changing. Access is the caller's
|
|
18
|
+
// to check.
|
|
19
|
+
import { Worker } from 'node:worker_threads'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
22
|
+
import { type Lru, lru } from './lru.ts'
|
|
23
|
+
import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
|
|
24
|
+
|
|
25
|
+
export interface NpmDelta { additions: number; deletions: number; typeless: { additions: number; deletions: number } | null; binary: boolean }
|
|
26
|
+
|
|
27
|
+
const MAX_ACTIVE = 2
|
|
28
|
+
const MAX_WAITING = 64
|
|
29
|
+
const DELTA_TIMEOUT_MS = 60_000
|
|
30
|
+
// The text the two sides may hold together, past which none is counted.
|
|
31
|
+
const MAX_TEXT_LENGTH = 64 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
const deltas: Lru<NpmDelta> = lru(20_000)
|
|
34
|
+
const working = new Map<string, Promise<NpmDelta>>()
|
|
35
|
+
let active = 0
|
|
36
|
+
const waiting: Array<() => void> = []
|
|
37
|
+
|
|
38
|
+
async function turn(deadline: number): Promise<void> {
|
|
39
|
+
if (active < MAX_ACTIVE) { active++; return }
|
|
40
|
+
if (waiting.length >= MAX_WAITING) throw new NpmPackageError(429, 'delta-busy')
|
|
41
|
+
await new Promise<void>((resolve, reject) => {
|
|
42
|
+
const next = () => { clearTimeout(timer); resolve() }
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
waiting.splice(waiting.indexOf(next), 1)
|
|
45
|
+
reject(new NpmPackageError(504, 'delta-timeout'))
|
|
46
|
+
}, deadline - Date.now())
|
|
47
|
+
waiting.push(next)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
function done() {
|
|
51
|
+
const next = waiting.shift()
|
|
52
|
+
if (next) next()
|
|
53
|
+
else active--
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lines = (told: unknown): told is { additions: number; deletions: number } => typeof told === 'object' && told !== null
|
|
57
|
+
&& Number.isSafeInteger((told as { additions?: unknown }).additions) && Number.isSafeInteger((told as { deletions?: unknown }).deletions)
|
|
58
|
+
// A delta as kept, none where it isn't one, or was kept before it told of
|
|
59
|
+
// binary files.
|
|
60
|
+
const deltaOf = (told: unknown): NpmDelta | null => lines(told) && ((told as NpmDelta).typeless === null || lines((told as NpmDelta).typeless))
|
|
61
|
+
&& typeof (told as NpmDelta).binary === 'boolean' ? { additions: told.additions, deletions: told.deletions, typeless: (told as NpmDelta).typeless,
|
|
62
|
+
binary: (told as NpmDelta).binary } : null
|
|
63
|
+
|
|
64
|
+
type Counted = { additions: number; deletions: number; typeAdditions: number; typeDeletions: number; typeFiles: number }
|
|
65
|
+
const isCounted = (told: unknown): told is Counted => typeof told === 'object' && told !== null
|
|
66
|
+
&& ['additions', 'deletions', 'typeAdditions', 'typeDeletions', 'typeFiles'].every(key => Number.isSafeInteger((told as Record<string, unknown>)[key]))
|
|
67
|
+
|
|
68
|
+
// What the worker counts of `base` against `other`, each its text files' paths and texts.
|
|
69
|
+
function counted(base: [string, string][], other: [string, string][], deadline: number): Promise<Counted> {
|
|
70
|
+
let worker: Worker
|
|
71
|
+
try {
|
|
72
|
+
worker = new Worker(new URL('./npm-delta-worker.js', import.meta.url), {
|
|
73
|
+
workerData: { base, other }, execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1536 },
|
|
74
|
+
})
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.warn('managed: a package delta could not start:', err)
|
|
77
|
+
throw new NpmPackageError(503, 'unavailable')
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
let answer: Counted | null = null, failure = new NpmPackageError(422, 'delta-failed')
|
|
81
|
+
const timer = setTimeout(() => { failure = new NpmPackageError(504, 'delta-timeout'); void worker.terminate() }, deadline - Date.now())
|
|
82
|
+
worker.once('message', (told: unknown) => {
|
|
83
|
+
if (isCounted(told)) answer = told
|
|
84
|
+
void worker.terminate()
|
|
85
|
+
})
|
|
86
|
+
worker.once('error', err => { console.warn('managed: a package delta failed:', err) })
|
|
87
|
+
worker.once('exit', () => {
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
if (answer) resolve(answer)
|
|
90
|
+
else reject(failure)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A side's text files, by path, how long their texts are, and its other,
|
|
96
|
+
// binary, files.
|
|
97
|
+
function texts(files: NpmPackageFile[] | null) {
|
|
98
|
+
let length = 0
|
|
99
|
+
const binaries: NpmPackageFile[] = []
|
|
100
|
+
const read = (files ?? []).flatMap(file => {
|
|
101
|
+
const text = npmFileText(file.bytes)
|
|
102
|
+
if (text === null) { binaries.push(file); return [] }
|
|
103
|
+
length += text.length
|
|
104
|
+
return [[file.path, text] as [string, string]]
|
|
105
|
+
})
|
|
106
|
+
return { read, length, binaries }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const same = (a: Uint8Array, b: Uint8Array) => Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(b)
|
|
110
|
+
const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('base64')
|
|
111
|
+
|
|
112
|
+
// Whether `news`, a side's binary files, differ from `olds`, the other's:
|
|
113
|
+
// one added, removed or changed, one moved where it is the same as one that
|
|
114
|
+
// was elsewhere not, matched by their digests, each file looked at once.
|
|
115
|
+
function binaryChanged(olds: NpmPackageFile[], news: NpmPackageFile[]): boolean {
|
|
116
|
+
if (olds.length !== news.length) return true
|
|
117
|
+
const left = new Map(olds.map(({ path, bytes }) => [path, bytes]))
|
|
118
|
+
const moved: Uint8Array[] = []
|
|
119
|
+
for (const { path, bytes } of news) {
|
|
120
|
+
const was = left.get(path)
|
|
121
|
+
if (was && same(was, bytes)) left.delete(path)
|
|
122
|
+
else moved.push(bytes)
|
|
123
|
+
}
|
|
124
|
+
// Those not where they were, by their digests, as many of each as there are.
|
|
125
|
+
const unmoved = new Map<string, number>()
|
|
126
|
+
for (const bytes of left.values()) {
|
|
127
|
+
const key = digest(bytes)
|
|
128
|
+
unmoved.set(key, (unmoved.get(key) ?? 0) + 1)
|
|
129
|
+
}
|
|
130
|
+
return moved.some(bytes => {
|
|
131
|
+
const key = digest(bytes)
|
|
132
|
+
const count = unmoved.get(key) ?? 0
|
|
133
|
+
if (count === 0) return true
|
|
134
|
+
unmoved.set(key, count - 1)
|
|
135
|
+
return false
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type Side = { integrity: string; files: () => Promise<NpmPackageFile[]> }
|
|
140
|
+
|
|
141
|
+
// The delta of `other` (its integrity, and how to read its files) against
|
|
142
|
+
// `base`, or none, as held, else shared with one asked before it, else
|
|
143
|
+
// looked for where it is kept, else worked out and kept: shared from before
|
|
144
|
+
// it is looked for, lest those asked together each look, find none, and work
|
|
145
|
+
// it out.
|
|
146
|
+
export function npmDelta(base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null = null): Promise<NpmDelta> {
|
|
147
|
+
const key = `${base?.integrity ?? '(empty)'}\0${other.integrity}`
|
|
148
|
+
const known = deltas.get(key)
|
|
149
|
+
if (known) return Promise.resolve(known)
|
|
150
|
+
const shared = working.get(key)
|
|
151
|
+
if (shared) return shared
|
|
152
|
+
const work = worked(key, base, other, kept).finally(() => working.delete(key))
|
|
153
|
+
working.set(key, work)
|
|
154
|
+
return work
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// As kept, else its turn awaited, then both sides' files read, counted and kept.
|
|
158
|
+
async function worked(key: string, base: Side | null, other: Side, kept: KeptStore<NpmDelta> | null): Promise<NpmDelta> {
|
|
159
|
+
const known = deltaOf((await kept?.read(key))?.value)
|
|
160
|
+
if (known) { deltas.set(key, known); return known }
|
|
161
|
+
await turn(Date.now() + DELTA_TIMEOUT_MS)
|
|
162
|
+
let binary, told
|
|
163
|
+
try {
|
|
164
|
+
// Its turn held till both sides are read, or have failed.
|
|
165
|
+
const [baseRead, otherRead] = await Promise.allSettled([base ? base.files() : Promise.resolve(null), other.files()])
|
|
166
|
+
if (baseRead.status === 'rejected') throw baseRead.reason
|
|
167
|
+
if (otherRead.status === 'rejected') throw otherRead.reason
|
|
168
|
+
const [olds, news] = [texts(baseRead.value), texts(otherRead.value)]
|
|
169
|
+
if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
|
|
170
|
+
binary = binaryChanged(olds.binaries, news.binaries)
|
|
171
|
+
told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
|
|
172
|
+
} finally { done() }
|
|
173
|
+
const delta = { additions: told.additions, deletions: told.deletions,
|
|
174
|
+
typeless: told.typeFiles > 0 ? { additions: told.additions - told.typeAdditions, deletions: told.deletions - told.typeDeletions } : null, binary }
|
|
175
|
+
deltas.set(key, delta)
|
|
176
|
+
await kept?.write(key, delta, Date.now())
|
|
177
|
+
return delta
|
|
178
|
+
}
|
|
@@ -362,25 +362,34 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
|
|
|
362
362
|
}
|
|
363
363
|
if (result.status !== 200) throw new NpmPackageError(502, 'upstream-unavailable')
|
|
364
364
|
const index = new Map(versions.map((version, i) => [version, i]))
|
|
365
|
-
// npm's registry answers an advisory once a range it covers
|
|
366
|
-
//
|
|
367
|
-
//
|
|
365
|
+
// npm's registry answers an advisory once a range it covers, and upstream
|
|
366
|
+
// answers the versions OSV's record of it, or its repository, covers beyond
|
|
367
|
+
// npm's under the same id, in a row of their own (as hermes-engine's
|
|
368
|
+
// GHSA-f5x2-xv93-4p23: npm's `<=0.4.3`, OSV's fixed in 0.5.2): here it is
|
|
369
|
+
// one row, the first's (npm's, then OSV's, then the repository's, as
|
|
370
|
+
// upstream orders them) with what it lacks taken from the rest, its ranges
|
|
371
|
+
// joined as semver joins them, the versions and CWEs they cover together,
|
|
372
|
+
// and the worst severity and highest score of them: a version only the
|
|
373
|
+
// repository reports keeps the severity it gives.
|
|
368
374
|
const merged = new Map<string, NpmAdvisory>()
|
|
369
375
|
for (const row of result.body) {
|
|
370
376
|
const affected = row.versions.map(version => index.get(version)).filter(at => at !== undefined)
|
|
371
|
-
const
|
|
372
|
-
|
|
377
|
+
const own: NpmAdvisory = {
|
|
378
|
+
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
379
|
+
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
380
|
+
...row.details && { details: row.details }, affected,
|
|
381
|
+
}
|
|
382
|
+
const known = merged.get(row.id)
|
|
373
383
|
if (!known) {
|
|
374
|
-
merged.set(
|
|
375
|
-
id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
|
|
376
|
-
...row.severity && { severity: row.severity }, ...row.cvss !== undefined && { cvss: row.cvss }, cwe: row.cwe, ...row.range && { range: row.range },
|
|
377
|
-
...row.details && { details: row.details }, affected,
|
|
378
|
-
})
|
|
384
|
+
merged.set(row.id, own)
|
|
379
385
|
continue
|
|
380
386
|
}
|
|
381
387
|
const range = [known.range, row.range].filter(Boolean).join(' || ')
|
|
382
388
|
const cvss = Math.max(known.cvss ?? -1, row.cvss ?? -1)
|
|
383
|
-
|
|
389
|
+
const rank = (severity: string | undefined) => SEVERITY_RANK[severity ?? ''] ?? 4
|
|
390
|
+
const severity = rank(row.severity) < rank(known.severity) ? row.severity : known.severity
|
|
391
|
+
merged.set(row.id, { ...own, ...known, ...range && { range }, ...severity && { severity }, ...cvss >= 0 && { cvss }, cwe: [...new Set([...known.cwe, ...row.cwe])],
|
|
392
|
+
affected: [...known.affected, ...affected] })
|
|
384
393
|
}
|
|
385
394
|
return {
|
|
386
395
|
versions, repository, ...keptAt && { keptAt },
|