@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/bundle-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +27 -15
- package/common/managed/team-app.d.ts +9 -0
- package/common/managed/team-app.js +59 -6
- package/common/workspace-app.js +38 -6
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +12 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +11 -2
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +34 -13
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-OGAYMEER.js +0 -232
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-VH4XMZIW.js +0 -2
- package/out/chunk-WYLQWYLA.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.54",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -105,8 +105,10 @@
|
|
|
105
105
|
"server-managed/bundle-build-diagnostics.d.ts",
|
|
106
106
|
"server-managed/bundle-catalog.ts",
|
|
107
107
|
"server-managed/bundle-commits.ts",
|
|
108
|
+
"server-managed/bundle-dependency-reviews.ts",
|
|
108
109
|
"server-managed/bundle-store.ts",
|
|
109
110
|
"server-managed/bundle-summary-cache.ts",
|
|
111
|
+
"server-managed/bundle-validation.ts",
|
|
110
112
|
"server-managed/bundle.ts",
|
|
111
113
|
"server-managed/cache-storage.ts",
|
|
112
114
|
"server-managed/cli.js",
|
|
@@ -141,6 +143,7 @@
|
|
|
141
143
|
"server-managed/github-oauth.ts",
|
|
142
144
|
"server-managed/github-pulls.ts",
|
|
143
145
|
"server-managed/github-requests.ts",
|
|
146
|
+
"server-managed/history-windows.ts",
|
|
144
147
|
"server-managed/http-response.ts",
|
|
145
148
|
"server-managed/http.ts",
|
|
146
149
|
"server-managed/link-reports.ts",
|
|
@@ -213,6 +216,7 @@
|
|
|
213
216
|
"server-managed/solidity-entry-points.ts",
|
|
214
217
|
"server-managed/sql-postgres.ts",
|
|
215
218
|
"server-managed/sql.ts",
|
|
219
|
+
"server-managed/stasis-config.ts",
|
|
216
220
|
"server-managed/static.ts",
|
|
217
221
|
"server-managed/storage.ts",
|
|
218
222
|
"server-managed/storage-command.ts",
|
|
@@ -225,6 +229,7 @@
|
|
|
225
229
|
"server-managed/team-feed.ts",
|
|
226
230
|
"server-managed/team-npm-scopes.ts",
|
|
227
231
|
"server-managed/team-app.ts",
|
|
232
|
+
"server-managed/team-app-summaries.ts",
|
|
228
233
|
"server-managed/team-reports.ts",
|
|
229
234
|
"server-managed/triage-response.ts",
|
|
230
235
|
"server-managed/type-only.ts",
|
|
@@ -414,16 +419,16 @@
|
|
|
414
419
|
"typescript": "^7.0.2"
|
|
415
420
|
},
|
|
416
421
|
"dependencies": {
|
|
417
|
-
"@exodus/stasis": "1.0.0-beta.
|
|
418
|
-
"@exodus/stasis-core": "1.0.0-beta.
|
|
419
|
-
"@preventive/archive": "^1.
|
|
422
|
+
"@exodus/stasis": "1.0.0-beta.15",
|
|
423
|
+
"@exodus/stasis-core": "1.0.0-beta.15",
|
|
424
|
+
"@preventive/archive": "^1.4.0",
|
|
420
425
|
"@preventive/compare": "^1.2.11",
|
|
421
426
|
"@preventive/diff": "^1.1.0",
|
|
422
|
-
"@preventive/lockfile": "^1.
|
|
427
|
+
"@preventive/lockfile": "^1.3.0",
|
|
423
428
|
"@preventive/publishers": "^1.1.0",
|
|
424
|
-
"@preventive/report": "^1.0.
|
|
429
|
+
"@preventive/report": "^1.0.1",
|
|
425
430
|
"@preventive/sourcemap": "1.0.0-alpha.1",
|
|
426
|
-
"@preventive/upstream": "^1.
|
|
431
|
+
"@preventive/upstream": "^1.7.0",
|
|
427
432
|
"css-tree": "^3.2.1",
|
|
428
433
|
"oxc-minify": "^0.152.0",
|
|
429
434
|
"oxc-parser": "^0.152.0",
|
package/risk/actions/resolve.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// A reference resolved on GitHub (`source`, index.d.ts ActionsSource): its
|
|
2
|
-
// repository, its ref's commit and its own code's findings
|
|
2
|
+
// repository, its ref's commit, its advisories and its own code's findings
|
|
3
|
+
// (README.md).
|
|
3
4
|
import { YamlError } from '@preventive/lockfile/yaml.js'
|
|
4
5
|
import { readWorkflow } from './workflow.js'
|
|
5
6
|
import { workflowFindings, workflowUses } from './findings.js'
|
|
@@ -168,7 +169,34 @@ async function pinnedOf(uses, info, source, tags) {
|
|
|
168
169
|
else if (tags) findings.push({ id: 'untagged-commit', severity: 'moderate', on: where })
|
|
169
170
|
}
|
|
170
171
|
if (uses.comment && !commentHolds(uses.comment, named)) findings.push({ id: 'comment-mismatch', severity: 'high', comment: uses.comment, tags: named })
|
|
171
|
-
return { commit, at: 'sha', findings }
|
|
172
|
+
return { commit, at: 'sha', named, findings }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The versions a reference's advisories are asked of, as upstream's `github`
|
|
176
|
+
// packages take them: the releases on the commit it runs (`v4.2.1`, not the
|
|
177
|
+
// `v4` moving with it), else a commit by its tags, or as any release where
|
|
178
|
+
// none names it, and a tag or a branch as named (a moving tag its whole line).
|
|
179
|
+
function advisedVersions(at, ref, commit, named) {
|
|
180
|
+
const releases = named.filter(tag => isRelease(tagVersion(tag)))
|
|
181
|
+
if (releases.length > 0) return releases
|
|
182
|
+
if (at === 'sha') return named.length > 0 ? named : [commit]
|
|
183
|
+
return [ref]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const RATED = new Set(['critical', 'high', 'moderate', 'low'])
|
|
187
|
+
|
|
188
|
+
// A repository's advisories at `versions` (`source.advisories`), each a
|
|
189
|
+
// finding: malware red, else as its source rates it, one unrated yellow. One
|
|
190
|
+
// not known public is `internal`, asked of its own advisories alone, its name
|
|
191
|
+
// sent to no other source; why not (`advisories-unavailable`) where they
|
|
192
|
+
// couldn't be had, the rest resolved all the same.
|
|
193
|
+
async function advisoryFindings(source, info, versions) {
|
|
194
|
+
if (source.advisories === undefined) return []
|
|
195
|
+
try {
|
|
196
|
+
return (await source.advisories(info.fullName, versions, info.private ?? true)).map(({ id, title, url, severity, malware, versions: affected }) => ({
|
|
197
|
+
id: 'advisory', severity: malware ? 'critical' : RATED.has(severity) ? severity : 'moderate', advisory: id, ...title && { title }, ...url && { url },
|
|
198
|
+
...malware && { malware: true }, versions: affected }))
|
|
199
|
+
} catch { return [{ id: 'advisories-unavailable', severity: 'notice' }] }
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
// A `$/path` (the repository's own at the commit running) as a reference.
|
|
@@ -202,9 +230,12 @@ export async function resolveAction(uses, source, { owner = null } = {}) {
|
|
|
202
230
|
if (!info) return { ...at, repository: null, commit: null, at: null, tags: [], findings, uses: [] }
|
|
203
231
|
const tags = await source.tags(info.fullName)
|
|
204
232
|
const ref = uses.pin === 'sha' ? await pinnedOf(uses, info, source, tags) : await refOf(uses, info, source, tags)
|
|
205
|
-
const
|
|
233
|
+
const runs = ref.commit && !ref.findings.some(({ id }) => id === 'commit-missing')
|
|
234
|
+
// What it runs, and its advisories at the versions that is.
|
|
235
|
+
const [code, advised] = runs ? await Promise.all([codeOf(uses, info, ref.commit, source),
|
|
236
|
+
advisoryFindings(source, info, advisedVersions(ref.at, uses.ref, ref.commit, ref.named ?? tagsOn(tags, ref.commit)))]) : [null, []]
|
|
206
237
|
return { ...at, repository: { fullName: info.fullName, private: Boolean(info.private) }, commit: ref.commit, at: ref.at, tags: tagsOn(tags, ref.commit),
|
|
207
|
-
findings: [...findings, ...ref.findings, ...code?.findings ?? []], uses: code?.uses ?? [] }
|
|
238
|
+
findings: [...findings, ...ref.findings, ...advised, ...code?.findings ?? []], uses: code?.uses ?? [] }
|
|
208
239
|
}
|
|
209
240
|
|
|
210
241
|
// `source` with its answers kept, by what was asked, while held: a scan's.
|
|
@@ -215,5 +246,5 @@ export function keptSource(source) {
|
|
|
215
246
|
if (!kept.has(key)) kept.set(key, source[method](...args).catch(error => { kept.delete(key); throw error }))
|
|
216
247
|
return kept.get(key)
|
|
217
248
|
}
|
|
218
|
-
return Object.fromEntries(['repo', 'tags', 'tag', 'branch', 'branches', 'contains', 'file'].map(method => [method, keep(method)]))
|
|
249
|
+
return Object.fromEntries(['repo', 'tags', 'tag', 'branch', 'branches', 'contains', 'file', 'advisories'].filter(method => source[method]).map(method => [method, keep(method)]))
|
|
219
250
|
}
|
package/risk/index.d.ts
CHANGED
|
@@ -68,7 +68,8 @@ export interface AssessNpmOptions {
|
|
|
68
68
|
advisories?: boolean
|
|
69
69
|
// Whether OSV's records of malware in the version are asked for too, where
|
|
70
70
|
// `advisories` are: false for a private package, whose name OSV is never
|
|
71
|
-
// to be sent
|
|
71
|
+
// to be sent, as upstream sends it none npm's registry doesn't serve
|
|
72
|
+
// anyone. True unless given.
|
|
72
73
|
osv?: boolean
|
|
73
74
|
// Whether a package is the caller's own, as its own scopes take: a new
|
|
74
75
|
// dependency on one is told no tier nor age, nor is npm asked of it. None
|
|
@@ -178,7 +179,8 @@ export function npmReadyDownload(name: string, options: { cache: CacheStore; sig
|
|
|
178
179
|
// Whether an advisory is of malware rather than a vulnerability: OSV's of a
|
|
179
180
|
// malicious package (`MAL-…`), or one such a record names (its `aliases`),
|
|
180
181
|
// CWE-506, embedded malicious code, whatever its title, or a title naming
|
|
181
|
-
// malware.
|
|
182
|
+
// malware. The rule @preventive/upstream's advisories tell `malware` by, for
|
|
183
|
+
// those it doesn't tell, as Dependabot's alerts.
|
|
182
184
|
export function npmMalwareAdvisory(advisory: { id?: string | undefined; aliases?: readonly string[] | undefined; cwe?: readonly string[] | undefined; title?: string | undefined }): boolean
|
|
183
185
|
|
|
184
186
|
export function npmFileRisk(path: string, text: string | null, bytes?: number, script?: boolean): unknown
|
|
@@ -235,6 +237,22 @@ export interface ActionsSource {
|
|
|
235
237
|
// commits, it holds for good.
|
|
236
238
|
contains(name: string, base: string, sha: string): Promise<boolean | null>
|
|
237
239
|
file(name: string, path: string, commit: string): Promise<string | null>
|
|
240
|
+
// A repository's advisories at `versions`, as upstream's `advisories`
|
|
241
|
+
// tells those of a `github` package: its own published ones, and but for
|
|
242
|
+
// one `internal` (not known public), OSV's of it as an Action. Without it,
|
|
243
|
+
// none are asked.
|
|
244
|
+
advisories?(name: string, versions: string[], internal: boolean): Promise<ActionAdvisory[]>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// An advisory of a repository, as upstream's `advisories` tells one.
|
|
248
|
+
export interface ActionAdvisory {
|
|
249
|
+
id: string
|
|
250
|
+
url?: string | undefined
|
|
251
|
+
title?: string | undefined
|
|
252
|
+
severity?: string | undefined
|
|
253
|
+
malware?: boolean | undefined
|
|
254
|
+
// The versions asked that it affects.
|
|
255
|
+
versions: string[]
|
|
238
256
|
}
|
|
239
257
|
|
|
240
258
|
// A reference resolved: its repository as GitHub has it now, its ref's
|
package/risk/npm/malware.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// files its malware advisories as CWE-506, embedded malicious code, whatever
|
|
10
10
|
// their title ("Critical severity vulnerability that affects event-stream
|
|
11
11
|
// and flatmap-stream"); and its title may say so where its CWE doesn't
|
|
12
|
-
// (@solana/web3.js's GHSA-jcxm-7wvp-g6p5, CWE-200 alone).
|
|
12
|
+
// (@solana/web3.js's GHSA-jcxm-7wvp-g6p5, CWE-200 alone). The rule upstream
|
|
13
|
+
// tells its own advisories' `malware` by, for those it doesn't tell, as
|
|
14
|
+
// Dependabot's alerts.
|
|
13
15
|
export const npmMalwareAdvisory = ({ id, aliases, cwe, title }) => [id, ...aliases ?? []].some(name => (name ?? '').startsWith('MAL-'))
|
|
14
16
|
|| (cwe ?? []).includes('CWE-506') || /\bmalware\b/iu.test(title ?? '')
|
|
15
17
|
|
|
@@ -27,10 +29,10 @@ export function npmPlaceholder(json) {
|
|
|
27
29
|
|
|
28
30
|
// The `malware` finding of a version, critical: its package taken down by npm
|
|
29
31
|
// (`takenDown`, its latest version npm's placeholder), and the advisories of
|
|
30
|
-
// malware among `advisories` (`{ id, url?, title?,
|
|
31
|
-
// title? }` once; null for neither.
|
|
32
|
+
// malware among `advisories` (`{ id, url?, title?, malware }`, as upstream
|
|
33
|
+
// tells it), each `{ id, url?, title? }` once; null for neither.
|
|
32
34
|
export function npmMalwareFinding({ takenDown, advisories }) {
|
|
33
|
-
const named = new Map(advisories.filter(
|
|
35
|
+
const named = new Map(advisories.filter(({ malware }) => malware).map(({ id, url, title }) => [id, { id, ...url ? { url } : {}, ...title ? { title } : {} }]))
|
|
34
36
|
if (!takenDown && named.size === 0) return null
|
|
35
37
|
return { id: 'malware', severity: 'critical', ...takenDown ? { takenDown: true } : {}, ...named.size > 0 ? { advisories: [...named.values()] } : {} }
|
|
36
38
|
}
|
package/risk/npm/registry.js
CHANGED
|
@@ -211,10 +211,12 @@ export async function npmPackageFacts(name, version, oldVersion, context) {
|
|
|
211
211
|
// npm's advisories of `version` of a package, as `npm audit` asks them, and
|
|
212
212
|
// where `osv`, OSV's records npm's registry hasn't, as the malicious
|
|
213
213
|
// packages' of nx's versions are (MAL-2025-41443, of GHSA-cxm3-wv7p-598c,
|
|
214
|
-
// which npm's registry hasn't): upstream's `advisories` and its `osv`,
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
214
|
+
// which npm's registry hasn't): upstream's `advisories` and its `osv`, which
|
|
215
|
+
// sends OSV the name only where npm's registry serves it anyone. Each `{ id,
|
|
216
|
+
// url?, title?, malware }`, whether it is of malware as upstream tells it, an
|
|
217
|
+
// advisory of npm's an OSV record of malware names among them; kept
|
|
218
|
+
// ADVISORIES_MS, those kept with no `malware` asked again, and given up on
|
|
219
|
+
// once `signal` aborts.
|
|
218
220
|
export async function npmVersionAdvisories(name, version, osv, context) {
|
|
219
221
|
const what = `${name}@${version}`
|
|
220
222
|
const { rows } = await kept(context, 'risk/npm/advisories', what, async () => {
|
|
@@ -222,8 +224,8 @@ export async function npmVersionAdvisories(name, version, osv, context) {
|
|
|
222
224
|
const asked = await untilAborted(advisories([{ ecosystem: 'npm', name, versions: [version] }], { cache: false, osv }), context.signal).catch(error => {
|
|
223
225
|
throw context.signal?.aborted ? error : new NpmRiskError(error?.status === 429 ? 'upstream-busy' : 'upstream-unavailable', `advisories of ${what} couldn't be had: ${error.message}`)
|
|
224
226
|
})
|
|
225
|
-
return { at: context.now, osv, rows: asked.map(({ id, url, title,
|
|
226
|
-
}, hit => context.now - hit.at < ADVISORIES_MS && hit.osv === osv)
|
|
227
|
+
return { at: context.now, osv, rows: asked.map(({ id, url, title, malware }) => ({ id, ...url ? { url } : {}, ...title ? { title } : {}, malware: malware === true })) }
|
|
228
|
+
}, hit => context.now - hit.at < ADVISORIES_MS && hit.osv === osv && hit.rows.every(row => typeof row.malware === 'boolean'))
|
|
227
229
|
return rows
|
|
228
230
|
}
|
|
229
231
|
|
|
@@ -1,83 +1,67 @@
|
|
|
1
1
|
import { type Advisory, type CacheStore, type Package, advisories } from '@preventive/upstream/advisories.js'
|
|
2
2
|
import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
|
|
3
|
-
import { isNpmPackageName, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
4
|
-
import { NPM_REGISTRY, askUpstream } from './npm-packages.ts'
|
|
5
3
|
|
|
6
4
|
export const ADVISORIES_TIMEOUT_MS = 30_000
|
|
7
|
-
const PUBLIC_AT_ONCE = 16
|
|
8
|
-
const order = (a: string, b: string) => Number(a > b) - Number(a < b)
|
|
9
|
-
|
|
10
|
-
// Whether anyone may read a package, so that its name may go to a service
|
|
11
|
-
// other than npm (OSV): an unscoped one always, npm's private packages
|
|
12
|
-
// always being scoped; a scoped one where npm answers it anonymously, asked
|
|
13
|
-
// by a HEAD, which reads nothing of it. One npm can't be asked of is taken
|
|
14
|
-
// as private, and not kept; the rest are, for PUBLIC_MS, PUBLIC_NAMES at
|
|
15
|
-
// most, as a package may be made private, or public. A name npm can't have
|
|
16
|
-
// is asked of no one, upstream refusing it.
|
|
17
|
-
const PUBLIC_MS = 60 * 60_000
|
|
18
|
-
const PUBLIC_NAMES = 10_000
|
|
19
|
-
const publicNames = new Map<string, { at: number; open: boolean }>()
|
|
20
|
-
async function isPublicNpmPackage(name: string, signal: AbortSignal): Promise<boolean> {
|
|
21
|
-
if (!isNpmPackageName(name)) return false
|
|
22
|
-
if (npmPackageScope(name) === null) return true
|
|
23
|
-
const kept = publicNames.get(name)
|
|
24
|
-
if (kept && Date.now() - kept.at < PUBLIC_MS) return kept.open
|
|
25
|
-
let open
|
|
26
|
-
try { open = await askUpstream(`${NPM_REGISTRY}/${name}`, { method: 'HEAD', limit: Number.MAX_SAFE_INTEGER, signal, absent: [401, 403, 404] }) !== null } catch { return false }
|
|
27
|
-
publicNames.delete(name)
|
|
28
|
-
publicNames.set(name, { at: Date.now(), open })
|
|
29
|
-
while (publicNames.size > PUBLIC_NAMES) publicNames.delete(publicNames.keys().next().value!)
|
|
30
|
-
return open
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// The npm packages anyone may read (isPublicNpmPackage), a few asked at a
|
|
34
|
-
// time, within PUBLIC_BUDGET_MS, a third of the audit's: a scoped one npm
|
|
35
|
-
// hasn't told of by then is taken as private, asked of npm alone, for the
|
|
36
|
-
// audit to keep the rest of its time.
|
|
37
|
-
const PUBLIC_BUDGET_MS = 10_000
|
|
38
|
-
async function publicNpmNames(packages: Package[], signal: AbortSignal): Promise<Set<string>> {
|
|
39
|
-
const names = [...new Set(packages.filter(pkg => pkg.ecosystem === 'npm').map(pkg => pkg.name))]
|
|
40
|
-
const open = new Set<string>()
|
|
41
|
-
const budget = new AbortController()
|
|
42
|
-
const timer = setTimeout(() => { budget.abort(new Error('public-check-budget')) }, PUBLIC_BUDGET_MS)
|
|
43
|
-
const asking = AbortSignal.any([signal, budget.signal])
|
|
44
|
-
try {
|
|
45
|
-
for (let at = 0; at < names.length; at += PUBLIC_AT_ONCE) {
|
|
46
|
-
const chunk = names.slice(at, at + PUBLIC_AT_ONCE)
|
|
47
|
-
const checked = await Promise.all(chunk.map(name => isPublicNpmPackage(name, asking)))
|
|
48
|
-
for (const [i, name] of chunk.entries()) if (checked[i]) open.add(name)
|
|
49
|
-
}
|
|
50
|
-
} finally { clearTimeout(timer) }
|
|
51
|
-
return open
|
|
52
|
-
}
|
|
53
5
|
|
|
54
6
|
function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
|
|
55
7
|
const authenticated = createClient({ token, userAgent: 'deepview-triage' })
|
|
56
8
|
let client = authenticated
|
|
9
|
+
// Published advisories, and whether a repository is public, remain
|
|
10
|
+
// readable after a token is revoked. Reuse anonymous access for subsequent
|
|
11
|
+
// repositories in this audit as well.
|
|
12
|
+
async function retried<T>(ask: (current: Client) => Promise<T>): Promise<T> {
|
|
13
|
+
const current = client
|
|
14
|
+
try { return await ask(current) } catch (error) {
|
|
15
|
+
if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
|
|
16
|
+
signal.throwIfAborted()
|
|
17
|
+
if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
|
|
18
|
+
return await ask(client)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
57
21
|
return {
|
|
58
22
|
...authenticated,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
23
|
+
listRepoAdvisories: options => retried(current => current.listRepoAdvisories(options)),
|
|
24
|
+
getRepo: options => retried(current => current.getRepo(options)),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// How many repositories GitHub is asked at once whether they are public, as
|
|
29
|
+
// upstream asks for their listings.
|
|
30
|
+
const REPOS_AT_ONCE = 4
|
|
31
|
+
|
|
32
|
+
// Whether GitHub says anyone may read `repo`: not one private, nor one it
|
|
33
|
+
// has none of for this client (gone, or not the caller's to read). Any other
|
|
34
|
+
// failure is thrown, as the audit then fails.
|
|
35
|
+
async function isPublicRepo(github: Client, repo: string): Promise<boolean> {
|
|
36
|
+
try { return (await github.getRepo({ repo, followRedirect: true }) as { private?: unknown } | null)?.private === false } catch (error) {
|
|
37
|
+
if (error instanceof HttpError && [404, 410, 451].includes(error.status)) return false
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The packages as upstream is asked of them. Upstream asks OSV of every
|
|
43
|
+
// `github` package by its name, as an Action, but of one `internal`: each is
|
|
44
|
+
// internal but where GitHub says its repository is public, so a private one,
|
|
45
|
+
// or one GitHub has none of, has its own published advisories read alone, as
|
|
46
|
+
// a private npm package's name never reaches OSV either. One that says
|
|
47
|
+
// (`internal`, as its caller knows) is taken as it says.
|
|
48
|
+
async function markInternal(packages: Package[], github: Client): Promise<Package[]> {
|
|
49
|
+
const internal = new Set(packages.flatMap(pkg => pkg.ecosystem === 'github' && pkg.internal === undefined ? [pkg.name] : []))
|
|
50
|
+
const repos = [...internal]
|
|
51
|
+
for (let at = 0; at < repos.length; at += REPOS_AT_ONCE) {
|
|
52
|
+
await Promise.all(repos.slice(at, at + REPOS_AT_ONCE).map(async repo => { if (await isPublicRepo(github, repo)) internal.delete(repo) }))
|
|
70
53
|
}
|
|
54
|
+
return packages.map(pkg => pkg.ecosystem === 'github' && internal.has(pkg.name) ? { ...pkg, internal: true } : pkg)
|
|
71
55
|
}
|
|
72
56
|
|
|
73
57
|
// The managed caller supplies its user's token when available; public shares
|
|
74
58
|
// remain anonymous. Upstream sends this credential only to GitHub and returns
|
|
75
59
|
// published advisories, matching versions across npm, OSV and repository rows:
|
|
76
60
|
// OSV's for npm packages anyone may read too, the malicious packages' records
|
|
77
|
-
// among them, which npm's registry hasn't
|
|
78
|
-
// `cache` keeps each repository's listing
|
|
79
|
-
//
|
|
80
|
-
// `githubToken`.
|
|
61
|
+
// among them, which npm's registry hasn't, each row of malware told so
|
|
62
|
+
// (`malware`). `cache` keeps each repository's listing, and the npm packages
|
|
63
|
+
// anyone may read (upstream-cache.ts), across audits; `github`, a client of
|
|
64
|
+
// the caller's, asks GitHub in place of one with `githubToken`.
|
|
81
65
|
export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
|
|
82
66
|
{ debug = false, repoAdvisories = false, details = false, githubToken = null, github, cache }: {
|
|
83
67
|
debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; github?: Client; cache?: CacheStore | undefined
|
|
@@ -95,17 +79,15 @@ export async function fetchBundleAdvisories(packages: Package[], signal: AbortSi
|
|
|
95
79
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
96
80
|
})
|
|
97
81
|
// npm packages anyone may read are asked of OSV too (upstream's `osv`),
|
|
98
|
-
// for the malicious packages' records npm's registry hasn't
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
const options = { github:
|
|
106
|
-
|
|
107
|
-
const result = await Promise.race([Promise.all(asked.map(({ part, osv }) => part.length === 0 ? Promise.resolve([]) : advisories(part, { ...options, osv }))), deadline])
|
|
108
|
-
return { status: 200, body: result.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name)) }
|
|
82
|
+
// for the malicious packages' records npm's registry hasn't: upstream
|
|
83
|
+
// sends OSV only the names npm's registry serves without a token, so a
|
|
84
|
+
// private one's never leaves for OSV, as it never does for Socket; nor an
|
|
85
|
+
// internal `github` package's (markInternal). Kept
|
|
86
|
+
// in the caller's store, or without one in upstream's disk cache, where
|
|
87
|
+
// the server set one (auditCache).
|
|
88
|
+
const client = github ?? advisoryGithubClient(githubToken, signal)
|
|
89
|
+
const options = { github: client, repoAdvisories, details, osv: true, ...(cache && { cache }) }
|
|
90
|
+
return { status: 200, body: await Promise.race([markInternal(packages, client).then(asked => advisories(asked, options)), deadline]) }
|
|
109
91
|
} catch (error) {
|
|
110
92
|
// Answered as upstream's failure alone: logged, its message, or with
|
|
111
93
|
// `debug` the whole error, but for a request its client gave up on. One
|
|
@@ -7,8 +7,53 @@ import { brotliOptions } from '@exodus/stasis-core/brotli'
|
|
|
7
7
|
import { HttpError, createClient } from '@preventive/upstream/github.js'
|
|
8
8
|
import { githubBundleFilename } from './bundle-build.ts'
|
|
9
9
|
import { bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
|
|
10
|
+
import { MAX_STASIS_CONFIG_BYTES, STASIS_CONFIG, parseStasisConfig } from './stasis-config.ts'
|
|
10
11
|
|
|
11
12
|
const regularFile = entry => entry.type === 'blob' && ['100644', '100755'].includes(entry.mode)
|
|
13
|
+
const parentOf = path => posix.dirname(path).replace(/^\.$/u, '')
|
|
14
|
+
|
|
15
|
+
// The entries the stasis.config.json in `directory` names (parseStasisConfig's),
|
|
16
|
+
// null where it has none. One whose entries Stasis can't tell is config-invalid.
|
|
17
|
+
async function directoryConfig(input, github, directory, client) {
|
|
18
|
+
const listing = await client.listRepoDir({ repo: github, sha: input.commit, directory: directory || undefined })
|
|
19
|
+
if (!listing.some(entry => entry.path === STASIS_CONFIG && regularFile(entry))) return null
|
|
20
|
+
const path = posix.join(directory, STASIS_CONFIG)
|
|
21
|
+
const text = await client.getRepoFile({ repo: github, path, ref: input.commit })
|
|
22
|
+
try {
|
|
23
|
+
if (Buffer.byteLength(text) > MAX_STASIS_CONFIG_BYTES) throw new Error(`${path} is larger than ${MAX_STASIS_CONFIG_BYTES} bytes`)
|
|
24
|
+
return parseStasisConfig(text, directory)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error('config-invalid', { cause: error })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The directory of the stasis.config.json listing the selected entries: for
|
|
31
|
+
// each entry, the nearest one at or above it whose `entries` names it. null
|
|
32
|
+
// where none lists any. Stasis reads the one in the directory it builds from
|
|
33
|
+
// alone, and builds the entries it lists alone: with others, or with another
|
|
34
|
+
// file's, they are config-mixed.
|
|
35
|
+
async function listingConfig(input, github, client) {
|
|
36
|
+
const configs = new Map()
|
|
37
|
+
const configAt = directory => {
|
|
38
|
+
if (!configs.has(directory)) configs.set(directory, directoryConfig(input, github, directory, client))
|
|
39
|
+
return configs.get(directory)
|
|
40
|
+
}
|
|
41
|
+
const found = await Promise.all(input.entries.map(async entry => {
|
|
42
|
+
for (let directory = parentOf(entry); ; directory = parentOf(directory)) {
|
|
43
|
+
const config = await configAt(directory)
|
|
44
|
+
if (config?.has(posix.relative(`/${directory}`, `/${entry}`))) return { entry, directory }
|
|
45
|
+
if (!directory) return { entry, directory: null }
|
|
46
|
+
}
|
|
47
|
+
}))
|
|
48
|
+
const listed = found.find(at => at.directory !== null)
|
|
49
|
+
if (!listed) return null
|
|
50
|
+
const other = found.find(at => at.directory !== listed.directory)
|
|
51
|
+
if (other) {
|
|
52
|
+
const where = at => at.directory === null ? 'none' : posix.join(at.directory, STASIS_CONFIG)
|
|
53
|
+
throw new Error('config-mixed', { cause: new Error(`${listed.entry} is listed in ${where(listed)}, ${other.entry} in ${where(other)}`) })
|
|
54
|
+
}
|
|
55
|
+
return listed.directory
|
|
56
|
+
}
|
|
12
57
|
|
|
13
58
|
async function projectDirectory(input, github, client) {
|
|
14
59
|
// Input validation permits either JS/TS entries or Solidity entries, never a mix.
|
|
@@ -61,7 +106,13 @@ export async function buildStasisBundle({ input, github, token, maxBytes, scopes
|
|
|
61
106
|
// disk. This fresh worker inherits no application credentials but NPM_TOKEN.
|
|
62
107
|
setCacheDir(cacheDir ?? false)
|
|
63
108
|
progress('build')
|
|
64
|
-
|
|
109
|
+
// Entries stasis.config.json lists are built from its directory, where
|
|
110
|
+
// Stasis builds each with its options there, taking no conditions; others,
|
|
111
|
+
// from their project with the conditions chosen.
|
|
112
|
+
const configured = await listingConfig(input, github, buildClient)
|
|
113
|
+
if (configured !== null && input.conditions !== null) throw new Error('config-conditions')
|
|
114
|
+
if (configured === null && input.conditions === null && !input.entries[0].endsWith('.sol')) throw new Error('bad-conditions')
|
|
115
|
+
const project = configured ?? await projectDirectory(input, github, buildClient)
|
|
65
116
|
if (!allowed(project)) throw new Error('build-scope')
|
|
66
117
|
const generate = await prismaGenerate(input, github, project, buildClient)
|
|
67
118
|
const { bundle } = await buildGitHubBundle({
|
|
@@ -86,10 +137,16 @@ export async function buildStasisBundle({ input, github, token, maxBytes, scopes
|
|
|
86
137
|
|
|
87
138
|
// The code a failed build answers with. Never upstream bodies or paths outside
|
|
88
139
|
// the caller's grants: those stay in the diagnostic. A missing or ambiguous
|
|
89
|
-
// lockfile, or the named one missing, is the lockfile's.
|
|
140
|
+
// lockfile, or the named one missing, is the lockfile's. Stasis refuses a
|
|
141
|
+
// stasis.config.json entry setting an option that runs a program, whatever
|
|
142
|
+
// is given (nothing of the repo runs); one it can't build with its options
|
|
143
|
+
// is the config's.
|
|
90
144
|
export function buildErrorCode(error, message) {
|
|
91
|
-
if (['build-scope', 'too-large'].includes(message)) return message
|
|
145
|
+
if (['build-scope', 'too-large', 'bad-conditions', 'config-invalid', 'config-mixed', 'config-conditions'].includes(message)) return message
|
|
92
146
|
if (error instanceof HttpError) return error.status === 429 ? 'github-rate-limited' : 'github-build-failed'
|
|
147
|
+
if (/, which runs .+, and nothing of the project runs here$/u.test(message)) return 'config-runs-code'
|
|
148
|
+
if (message.endsWith(': bundle either the entries it names or others, not both')) return 'config-mixed'
|
|
149
|
+
if (/\bstasis\.config\.json(?: is not valid JSON|: entries)/u.test(message)) return 'config-invalid'
|
|
93
150
|
return /no packageManager given|lockfile installs|no (?:pnpm-lock\.yaml|yarn\.lock|package-lock\.json|soldeer\.lock) found in /u.test(message) ? 'build-lockfile' : 'build-failed'
|
|
94
151
|
}
|
|
95
152
|
|
|
@@ -110,6 +167,6 @@ if (parentPort && workerData?.type === 'managed-bundle-build') {
|
|
|
110
167
|
} catch (error) {
|
|
111
168
|
const diagnostic = bundleBuildDiagnostic(error, workerData.token)
|
|
112
169
|
const code = buildErrorCode(error, diagnostic.message)
|
|
113
|
-
send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422, diagnostic })
|
|
170
|
+
send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : code === 'bad-conditions' ? 400 : 422, diagnostic })
|
|
114
171
|
}
|
|
115
172
|
}
|
|
@@ -18,7 +18,9 @@ export interface BundleBuildInput {
|
|
|
18
18
|
entries: string[]
|
|
19
19
|
directory: string
|
|
20
20
|
options: { conditions?: string[]; mainFields?: string[]; metro?: boolean; platforms?: string[]; jsx?: boolean; packageManager?: 'soldeer' }
|
|
21
|
-
// What the stored bundle records; null for Solidity, which takes no
|
|
21
|
+
// What the stored bundle records; null for Solidity, which takes no
|
|
22
|
+
// conditions, and for entries given none, which stasis.config.json must
|
|
23
|
+
// then list (the build checks: bundle-build-worker.js).
|
|
22
24
|
conditions: BundleBuildConditions | null
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -27,6 +29,13 @@ function path(value: unknown): value is string {
|
|
|
27
29
|
&& value.split('/').every(part => part !== '' && part !== '.' && part !== '..')
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
const isScript = (entry: string) => /\.[cm]?[tj]sx?$/iu.test(entry)
|
|
33
|
+
|
|
34
|
+
// Whether a build takes `value` as an entry point, with others in its language.
|
|
35
|
+
export function buildableEntry(value: unknown): value is string {
|
|
36
|
+
return path(value) && (isScript(value) || value.endsWith('.sol'))
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export function parseBundleBuild(value: unknown): BundleBuildInput {
|
|
31
40
|
const fail = (code = 'bad-request'): never => { throw new BundleBuildError(400, code) }
|
|
32
41
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) return fail()
|
|
@@ -35,7 +44,7 @@ export function parseBundleBuild(value: unknown): BundleBuildInput {
|
|
|
35
44
|
|| typeof commit !== 'string' || !/^[a-f\d]{40}$/u.test(commit)
|
|
36
45
|
|| !Array.isArray(entries) || entries.length === 0 || entries.length > 100 || !entries.every(path)) return fail()
|
|
37
46
|
const selected = [...new Set(entries as string[])]
|
|
38
|
-
const scripts = selected.every(
|
|
47
|
+
const scripts = selected.every(isScript)
|
|
39
48
|
if (!scripts && !selected.every(entry => entry.endsWith('.sol'))) return fail('unsupported-entries')
|
|
40
49
|
const parts = posix.dirname(selected[0]!).split('/').filter(part => part !== '.')
|
|
41
50
|
for (const entry of selected) {
|
|
@@ -46,8 +55,8 @@ export function parseBundleBuild(value: unknown): BundleBuildInput {
|
|
|
46
55
|
// above the project no longer leaves it unable to tell which one installs it.
|
|
47
56
|
const options: BundleBuildInput['options'] = scripts ? {} : { packageManager: 'soldeer' }
|
|
48
57
|
let recorded: BundleBuildConditions | null = null
|
|
49
|
-
if (scripts) {
|
|
50
|
-
if (
|
|
58
|
+
if (scripts && conditions != null) {
|
|
59
|
+
if (typeof conditions !== 'object' || Array.isArray(conditions)) return fail('bad-conditions')
|
|
51
60
|
const { preset, conditions: names, platforms } = conditions as Record<string, unknown>
|
|
52
61
|
if (typeof preset !== 'string' || !['node', 'browser', 'metro'].includes(preset) || !Array.isArray(names) || names.length > 16
|
|
53
62
|
|| !names.every(name => typeof name === 'string' && name.length > 0 && name.length <= 64
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What a bundle's npm dependencies want of review, in a repository that
|
|
2
|
+
// reviews dependencies (repository-dependency-reviews.ts), as its Overview
|
|
3
|
+
// tells it: the package versions it bundles (bundle-advisory-inventory.ts),
|
|
4
|
+
// each as the lockfiles that install it lock it at the commit it was built
|
|
5
|
+
// from, whence their integrities, sources and patches, which reviews are held
|
|
6
|
+
// to (pull-dependency-reviews.ts), judged as the other versions a pull
|
|
7
|
+
// request's lockfiles lock are: by reviews of them whole or compared with any
|
|
8
|
+
// version of their package, else passed by the commit it is reviewed since
|
|
9
|
+
// or the host's reviewed dependencies.
|
|
10
|
+
//
|
|
11
|
+
// Its lockfiles are those its review holds to (lockfile-scope.ts) in its
|
|
12
|
+
// directory or a directory above it, as an install of it reads one there, a
|
|
13
|
+
// workspace's at its root; each version as the nearest of their directories
|
|
14
|
+
// that locks it locks it, every integrity and patch it locks it with. One no
|
|
15
|
+
// lockfile there locks (`unlocked`) can't be reviewed, as there is nothing to
|
|
16
|
+
// hold its reviews to: it fails the review, as one that can't be reviewed
|
|
17
|
+
// does. Where its review holds to none of the lockfiles there, but there are
|
|
18
|
+
// some, its dependencies aren't reviewed (`reviewedIn`).
|
|
19
|
+
import { type CommitLockfiles, lockedIn } from './commit-lockfiles.ts'
|
|
20
|
+
import type { LockedPackage } from './locked-packages.ts'
|
|
21
|
+
import { MAX_LOCKED, type PullDependencies, lockedWith, lockfileFormat } from './pull-dependencies.ts'
|
|
22
|
+
import { MAX_UNREVIEWED } from './pull-dependency-reviews.ts'
|
|
23
|
+
|
|
24
|
+
// A version a bundle holds: its package's name and its version.
|
|
25
|
+
export interface BundledVersion { name: string; version: string }
|
|
26
|
+
|
|
27
|
+
// The directory a lockfile at `path` is in, '' for the repository's root.
|
|
28
|
+
const directoryOf = (path: string) => path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
|
|
29
|
+
// A bundle's directory as one is compared: without the slashes either end.
|
|
30
|
+
const bundleDirectory = (directory: string) => directory.replaceAll(/^\/+|\/+$/gu, '')
|
|
31
|
+
const depth = (dir: string) => dir === '' ? 0 : dir.split('/').length
|
|
32
|
+
|
|
33
|
+
// Whether the lockfile at `path` may install the bundle in `directory`: one
|
|
34
|
+
// in it, or in a directory above it.
|
|
35
|
+
export function installsBundle(path: string, directory: string): boolean {
|
|
36
|
+
const at = bundleDirectory(directory), dir = directoryOf(path)
|
|
37
|
+
return dir === '' || at === dir || at.startsWith(`${dir}/`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The versions `bundled` as `read`'s lockfiles (those installing the bundle,
|
|
41
|
+
// read at `commit`) lock them, as one directory of a pull request's
|
|
42
|
+
// lockfiles (pull-dependencies.ts) whose versions it adds none of, each as
|
|
43
|
+
// the nearest of their directories locking it locks it, those past
|
|
44
|
+
// MAX_LOCKED counted (`moreLocked`), and each lockfile left unread a
|
|
45
|
+
// directory of its own failing the review; with those none lock
|
|
46
|
+
// (`unlocked`, as many as MAX_UNREVIEWED, how many more `moreUnlocked`) and
|
|
47
|
+
// how many versions it bundles (`versions`), each once.
|
|
48
|
+
export function bundleDependencies(bundled: BundledVersion[], read: CommitLockfiles, commit: string) {
|
|
49
|
+
const versions = [...new Map(bundled.map(one => [`${one.name}@${one.version}`, one])).values()]
|
|
50
|
+
const byDirectory = new Map<string, string[]>()
|
|
51
|
+
for (const { path } of read.lockfiles) byDirectory.set(directoryOf(path), [...byDirectory.get(directoryOf(path)) ?? [], path])
|
|
52
|
+
// Each directory's versions by their name and version, the nearest first.
|
|
53
|
+
const nearest = [...byDirectory].toSorted(([a], [b]) => depth(b) - depth(a)).map(([dir, paths]) => {
|
|
54
|
+
const locked = new Map<string, LockedPackage[]>()
|
|
55
|
+
for (const pkg of lockedIn(read, path => directoryOf(path) === dir).packages) {
|
|
56
|
+
const key = `${pkg.name}@${pkg.version ?? ''}`
|
|
57
|
+
locked.set(key, [...locked.get(key) ?? [], pkg])
|
|
58
|
+
}
|
|
59
|
+
return { paths, locked }
|
|
60
|
+
})
|
|
61
|
+
const found = new Map<string, LockedPackage>()
|
|
62
|
+
const unlocked: BundledVersion[] = []
|
|
63
|
+
for (const one of versions) {
|
|
64
|
+
const variants = nearest.find(({ locked }) => locked.has(`${one.name}@${one.version}`))?.locked.get(`${one.name}@${one.version}`)
|
|
65
|
+
if (!variants) { unlocked.push(one); continue }
|
|
66
|
+
for (const pkg of variants) if (!found.has(lockedWith(pkg))) found.set(lockedWith(pkg), pkg)
|
|
67
|
+
}
|
|
68
|
+
const locked = [...found.values()]
|
|
69
|
+
const told: PullDependencies = { base: commit, head: commit, cut: false, lockfiles: [
|
|
70
|
+
{ paths: nearest.flatMap(({ paths }) => paths), added: [], more: 0, locked: locked.slice(0, MAX_LOCKED), moreLocked: Math.max(0, locked.length - MAX_LOCKED) },
|
|
71
|
+
...read.unread.map(path => ({ paths: [path], error: lockfileFormat(path) === 'bun' ? 'lockfile-unsupported' : 'lockfile-unreadable' })),
|
|
72
|
+
] }
|
|
73
|
+
return { told, versions: versions.length, unlocked: unlocked.slice(0, MAX_UNREVIEWED), moreUnlocked: Math.max(0, unlocked.length - MAX_UNREVIEWED) }
|
|
74
|
+
}
|