@preventive/triage 1.0.0-alpha.53 → 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.
Files changed (109) hide show
  1. package/common/bundle-compare-diff.js +28 -9
  2. package/common/bundle-compare-inputs.d.ts +1 -0
  3. package/common/bundle-compare-inputs.js +91 -15
  4. package/common/bundle-comparison.js +14 -7
  5. package/common/default-scan-models.ts +3 -0
  6. package/common/dependency-reviews.d.ts +1 -1
  7. package/common/dependency-reviews.js +1 -1
  8. package/common/managed/finding-detail.d.ts +1 -1
  9. package/common/managed/finding-detail.js +7 -7
  10. package/common/managed/team-app.d.ts +7 -0
  11. package/common/managed/team-app.js +31 -7
  12. package/common/workspace-app.js +19 -7
  13. package/out/activity.js +4 -4
  14. package/out/chunk-4EMM6UCD.js +1 -0
  15. package/out/chunk-4ISO5F7Y.js +7 -0
  16. package/out/chunk-4XIH7YTX.js +1 -0
  17. package/out/chunk-6W5BMATD.js +1 -0
  18. package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
  19. package/out/chunk-DEVAORP2.js +1 -0
  20. package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
  21. package/out/chunk-E3LFSUDJ.js +9 -0
  22. package/out/chunk-FHIVVVUA.js +6 -0
  23. package/out/chunk-G3LY2FV4.js +1 -0
  24. package/out/chunk-HAZKDK4M.js +4 -0
  25. package/out/chunk-HOB3YPS5.js +1 -0
  26. package/out/chunk-I47TYPXO.js +52 -0
  27. package/out/chunk-J7A5OSZE.js +244 -0
  28. package/out/chunk-JFL7WLRV.js +1 -0
  29. package/out/chunk-JMG5DXII.js +2 -0
  30. package/out/chunk-JOXLZXMI.js +1 -0
  31. package/out/chunk-L4CTEMRG.js +2 -0
  32. package/out/chunk-M37X4LCF.js +14 -0
  33. package/out/chunk-M3Y6SLC4.js +1 -0
  34. package/out/chunk-NXATZJQ2.js +1 -0
  35. package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
  36. package/out/chunk-PAD2JHZT.js +1 -0
  37. package/out/chunk-QMVOEBWC.js +13 -0
  38. package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
  39. package/out/chunk-RRBIJL2B.js +1 -0
  40. package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
  41. package/out/chunk-TOXMUOIW.js +5 -0
  42. package/out/chunk-UBOCLLF2.js +1 -0
  43. package/out/chunk-UCP3JRE2.js +1 -0
  44. package/out/{chunk-JEFZTVNX.js → chunk-VC72ZO3P.js} +1 -1
  45. package/out/chunk-VRX2APNA.js +9 -0
  46. package/out/{chunk-NE2UGKRJ.js → chunk-VZMELPVL.js} +4 -3
  47. package/out/{chunk-YA4ETCFU.js → chunk-WHVXTJHK.js} +1 -1
  48. package/out/chunk-WMFDQS2L.js +11 -0
  49. package/out/chunk-WNSVBOSO.js +41 -0
  50. package/out/chunk-WREU7JAG.js +1 -0
  51. package/out/chunk-Y5ZHFNBD.js +2 -0
  52. package/out/chunk-YF4M5OCI.js +1 -0
  53. package/out/chunk-Z667U2UJ.js +1 -0
  54. package/out/chunk-ZQHLECTI.js +1 -0
  55. package/out/client-managed-import.js +1 -1
  56. package/out/client-managed.js +27 -26
  57. package/out/compare.js +14 -14
  58. package/out/dialogs.js +36 -51
  59. package/out/graph.js +17 -15
  60. package/out/index.html +26 -10
  61. package/out/prism.js +1 -1
  62. package/out/scan.js +2 -2
  63. package/out/terminal.js +232 -232
  64. package/out/view.css +1 -1
  65. package/out/view.js +15 -15
  66. package/package.json +11 -7
  67. package/risk/actions/resolve.js +36 -5
  68. package/risk/index.d.ts +20 -2
  69. package/risk/npm/malware.js +6 -4
  70. package/risk/npm/registry.js +8 -6
  71. package/server-managed/bundle-advisories.ts +56 -74
  72. package/server-managed/bundle-build-worker.js +61 -4
  73. package/server-managed/bundle-build.ts +13 -4
  74. package/server-managed/bundle-dependency-reviews.ts +74 -0
  75. package/server-managed/bundle-validation.ts +32 -0
  76. package/server-managed/db-methods.ts +51 -16
  77. package/server-managed/db-neon.ts +18 -2
  78. package/server-managed/db.ts +11 -2
  79. package/server-managed/github-issue-links.ts +151 -47
  80. package/server-managed/github-pulls.ts +34 -15
  81. package/server-managed/http.ts +227 -12
  82. package/server-managed/index.ts +13 -1
  83. package/server-managed/managed-issues.ts +29 -15
  84. package/server-managed/npm-insights.ts +4 -3
  85. package/server-managed/npm-kept.ts +4 -0
  86. package/server-managed/package-entry-points.ts +37 -9
  87. package/server-managed/pretty-print.ts +7 -5
  88. package/server-managed/public-workspace.ts +2 -2
  89. package/server-managed/repository-actions.ts +7 -4
  90. package/server-managed/repository-browser.ts +11 -4
  91. package/server-managed/repository-risk-audit.ts +4 -5
  92. package/server-managed/repository-risk.ts +4 -4
  93. package/server-managed/sql.ts +2 -1
  94. package/server-managed/stasis-config.ts +46 -0
  95. package/server-managed/team-app-summaries.ts +54 -0
  96. package/server-managed/team-app.ts +52 -31
  97. package/server-managed/team-reports.ts +27 -7
  98. package/server-managed/upstream-cache.ts +41 -32
  99. package/server-managed/workspace-shares.ts +3 -1
  100. package/out/chunk-2ZDZWLFY.js +0 -1
  101. package/out/chunk-7VAWINQF.js +0 -6
  102. package/out/chunk-FMXWXDR4.js +0 -36
  103. package/out/chunk-IZ7POWI3.js +0 -18
  104. package/out/chunk-LJ7XRXFU.js +0 -52
  105. package/out/chunk-QB4CBOX2.js +0 -1
  106. package/out/chunk-TQJDYHVE.js +0 -2
  107. package/out/chunk-TSIFIFBX.js +0 -232
  108. package/out/chunk-VH4XMZIW.js +0 -2
  109. 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.53",
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",
@@ -214,6 +216,7 @@
214
216
  "server-managed/solidity-entry-points.ts",
215
217
  "server-managed/sql-postgres.ts",
216
218
  "server-managed/sql.ts",
219
+ "server-managed/stasis-config.ts",
217
220
  "server-managed/static.ts",
218
221
  "server-managed/storage.ts",
219
222
  "server-managed/storage-command.ts",
@@ -226,6 +229,7 @@
226
229
  "server-managed/team-feed.ts",
227
230
  "server-managed/team-npm-scopes.ts",
228
231
  "server-managed/team-app.ts",
232
+ "server-managed/team-app-summaries.ts",
229
233
  "server-managed/team-reports.ts",
230
234
  "server-managed/triage-response.ts",
231
235
  "server-managed/type-only.ts",
@@ -415,16 +419,16 @@
415
419
  "typescript": "^7.0.2"
416
420
  },
417
421
  "dependencies": {
418
- "@exodus/stasis": "1.0.0-beta.11",
419
- "@exodus/stasis-core": "1.0.0-beta.11",
420
- "@preventive/archive": "^1.2.0",
422
+ "@exodus/stasis": "1.0.0-beta.15",
423
+ "@exodus/stasis-core": "1.0.0-beta.15",
424
+ "@preventive/archive": "^1.4.0",
421
425
  "@preventive/compare": "^1.2.11",
422
426
  "@preventive/diff": "^1.1.0",
423
- "@preventive/lockfile": "^1.2.0",
427
+ "@preventive/lockfile": "^1.3.0",
424
428
  "@preventive/publishers": "^1.1.0",
425
- "@preventive/report": "^1.0.0",
429
+ "@preventive/report": "^1.0.1",
426
430
  "@preventive/sourcemap": "1.0.0-alpha.1",
427
- "@preventive/upstream": "^1.3.0",
431
+ "@preventive/upstream": "^1.7.0",
428
432
  "css-tree": "^3.2.1",
429
433
  "oxc-minify": "^0.152.0",
430
434
  "oxc-parser": "^0.152.0",
@@ -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 (README.md).
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 code = ref.commit && !ref.findings.some(({ id }) => id === 'commit-missing') ? await codeOf(uses, info, ref.commit, source) : null
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. True unless given.
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
@@ -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?, cwe }`), each `{ id, url?,
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(npmMalwareAdvisory).map(({ id, url, title }) => [id, { id, ...url ? { url } : {}, ...title ? { title } : {} }]))
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
  }
@@ -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`, an
215
- // advisory of npm's an OSV record names taking the record's ids among its
216
- // `aliases`. Each `{ id, url?, title?, cwe, aliases }`, kept ADVISORIES_MS,
217
- // and given up on once `signal` aborts.
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, cwe, aliases }) => ({ id, ...url ? { url } : {}, ...title ? { title } : {}, cwe, aliases })) }
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
- async listRepoAdvisories(options) {
60
- const current = client
61
- try { return await current.listRepoAdvisories(options) } catch (error) {
62
- if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
63
- signal.throwIfAborted()
64
- // Published advisories remain readable after a token is revoked. Reuse
65
- // anonymous access for subsequent repositories in this audit as well.
66
- if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
67
- return await client.listRepoAdvisories(options)
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 (upstream-cache.ts) across audits;
79
- // `github`, a client of the caller's, asks GitHub in place of one with
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; a private
99
- // one's name never leaves for OSV, as it never does for Socket. A package
100
- // is wholly in one ask or the other, so upstream's order holds.
101
- const open = await Promise.race([publicNpmNames(packages, signal), deadline])
102
- const toOsv = (pkg: Package) => pkg.ecosystem === 'npm' && open.has(pkg.name)
103
- // Kept in the caller's store, or without one in upstream's disk cache,
104
- // where the server set one (auditCache).
105
- const options = { github: github ?? advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }
106
- const asked = [{ part: packages.filter(toOsv), osv: true }, { part: packages.filter(pkg => !toOsv(pkg)), osv: false }]
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
- const project = await projectDirectory(input, github, buildClient)
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 conditions.
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(entry => /\.[cm]?[tj]sx?$/iu.test(entry))
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 (conditions == null || typeof conditions !== 'object' || Array.isArray(conditions)) return fail('bad-conditions')
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
+ }
@@ -0,0 +1,32 @@
1
+ // A Stasis bundle validated (GET and POST /api/bundles/:id/validation) by
2
+ // Stasis's own validation (`@exodus/stasis/validate`; its rules are in
3
+ // Stasis's doc/validate.md), the repository read the app's way: with its
4
+ // reader's GitHub access, own code by git's blob ids of the commit's listing.
5
+ import { type BundleValidation, type ValidationPlan, followingMoves, validateBundle, validateRepository } from '@exodus/stasis/validate'
6
+ import { createClient } from '@preventive/upstream/github.js'
7
+ import { upstreamGithub } from './github-errors.ts'
8
+
9
+ export { type BundleValidation, type PlannedBundle, validationPlan } from '@exodus/stasis/validate'
10
+
11
+ // The rules a validation was made by, kept with it: one made by others is made again.
12
+ export const BUNDLE_VALIDATION_READ = 3
13
+
14
+ type Options = { deadline?: number }
15
+
16
+ // The bundle `plan`'s dependencies alone, held to their registry, own code unverified for `reason`.
17
+ export const validateDependencies = (plan: ValidationPlan, reason: string, options: Options = {}): Promise<BundleValidation> =>
18
+ validateBundle(plan, { reason }, options)
19
+
20
+ // The bundle `plan` validated at `commit` of `fullName`, read with `token`,
21
+ // built in `directory`: own code held to git's blob ids of the commit's tree
22
+ // as upstream lists it (each listing held to its tree's id), the commit to the
23
+ // repository's own history, the dependencies to the lockfiles installing it;
24
+ // what can't be held to told in the report (own code's `reason`,
25
+ // `lockfileError`), not thrown.
26
+ export function validateAtCommit(plan: ValidationPlan, { fullName, token, commit, directory }: { fullName: string; token: string; commit: string; directory: string },
27
+ options: Options = {}): Promise<BundleValidation> {
28
+ const client = createClient({ token, userAgent: 'deepview-triage' })
29
+ const repository = followingMoves(client, fullName)
30
+ return upstreamGithub(() => validateRepository(plan, { repository, commit, directory },
31
+ { ...options, strict: false, listTree: paths => client.listRepoTree({ repo: repository.name(), sha: commit, paths }) }))
32
+ }