@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
@@ -6,6 +6,9 @@ import { ensureUserAccessToken } from './github-oauth.ts'
6
6
 
7
7
  type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
8
8
  const MAX_GITHUB_LOOKUPS = 200
9
+ // A load reading this many open or unknown items anew also rechecks one closed
10
+ // item, rounded up (lookupFixes).
11
+ const CLOSED_RECHECK_EVERY = 10
9
12
  const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
10
13
  const GITHUB_METADATA_TTL_MS = 60_000
11
14
  const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
@@ -132,6 +135,32 @@ async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl:
132
135
  } catch { return null }
133
136
  }
134
137
 
138
+ // What a load reads of its `entries`, by what is cached of them at `now`:
139
+ // those missing or stale, the least recently checked first, as many as
140
+ // MAX_GITHUB_LOOKUPS. Closed items are kept once fetched, but GitHub can
141
+ // reopen them (merged ones are final): a load reading open or unknown items
142
+ // anew also rechecks a tenth as many closed ones, the least recently checked
143
+ // first, after them. None where every item is closed or fresh, and a busy
144
+ // workspace cycles through its closed items, a recheck rotating its item
145
+ // behind the others whether or not GitHub answers.
146
+ function readsOf<Entry extends [string, GithubFixRef]>(entries: Entry[], metadata: Map<string, GithubMetadata>, now: number): Entry[] {
147
+ const lastCheckedAt = ([key]: Entry) => Math.max(metadata.get(key)?.fetchedAt ?? 0, metadata.get(key)?.attemptedAt ?? 0)
148
+ const oldestFirst = (a: Entry, b: Entry) => lastCheckedAt(a) - lastCheckedAt(b)
149
+ const stale = ([key, ref]: Entry) => {
150
+ const cached = metadata.get(key)
151
+ // Backfill closed issues cached before closure reasons existed, within the
152
+ // same authorized/capped queue. Even an unknown reason completes backfill.
153
+ return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
154
+ || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
155
+ }
156
+ const pending = entries.filter(stale).toSorted(oldestFirst).slice(0, MAX_GITHUB_LOOKUPS)
157
+ // Stale ones are missing, open, draft, or closed issues awaiting backfill.
158
+ const reread = pending.filter(([key]) => metadata.get(key)?.status !== 'closed').length
159
+ const rechecks = entries.filter(entry => metadata.get(entry[0])?.status === 'closed' && !stale(entry))
160
+ .toSorted(oldestFirst).slice(0, Math.ceil(reread / CLOSED_RECHECK_EVERY))
161
+ return [...pending, ...rechecks]
162
+ }
163
+
135
164
  // The workspace handler checks user -> team membership and finding visibility.
136
165
  // Shared private metadata also requires this viewer's live GitHub access,
137
166
  // including for admins. Only recently verified public repos skip that check.
@@ -169,27 +198,17 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
169
198
  const readable = await readableRepositories(repositories, context)
170
199
  const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
171
200
  const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
172
- const lastCheckedAt = (key: string) => {
173
- const cached = metadata.get(key)
174
- return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
175
- }
176
- const pending = authorized.filter(([key, ref]) => {
177
- const cached = metadata.get(key)
178
- // Backfill closed issues cached before closure reasons existed, within the
179
- // same authorized/capped queue. Even an unknown reason completes backfill.
180
- return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
181
- || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
182
- }).toSorted(([a], [b]) => lastCheckedAt(a) - lastCheckedAt(b)).slice(0, MAX_GITHUB_LOOKUPS)
201
+ const reads = readsOf(authorized, metadata, now)
183
202
  // Only stale/missing, distinct, authorized entries consume the request budget.
184
203
  // Prioritize missing/oldest entries so a large workspace makes progress.
185
- if (pending.length > 0) {
186
- const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
204
+ if (reads.length > 0) {
205
+ const publicRefreshes = new Set(reads.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
187
206
  if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
188
207
  const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
189
208
  const attempted: string[] = []
190
209
  // All waves and access checks share one deadline and four-call limit.
191
- for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
192
- await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
210
+ for (let i = 0; i < reads.length && !signal.aborted; i += 4) {
211
+ await Promise.all(reads.slice(i, i + 4).map(async ([key, ref]) => {
193
212
  if (signal.aborted) return
194
213
  if (metadata.has(key)) attempted.push(key)
195
214
  const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
@@ -76,6 +76,10 @@
76
76
  // GET /api/admin/reports/<id>/location → admin|manage live location suggestion | 401/403/404/503
77
77
  // GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
78
78
  // GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
79
+ // GET /api/bundles/<id>/dependency-review → what the npm versions it bundles want of review, as the lockfiles installing it lock
80
+ // them at the commit it was built from, where its repository's review is in effect for a reader of all of it | 401/404/409/413/422/429/502
81
+ // GET /api/bundles/<id>/validation → its last validation the reader may see, else none; POST validates it anew: its own code
82
+ // as GitHub has it at its commit, its dependencies as upstream's packages have them, those covering every file | 401/403/404/409/422/429/502
79
83
  // GET /api/bundles/<id>/download → authorized original upload | 401/404
80
84
  // GET /api/bundles/<id>/pretty?path=&hash= → one of its files pretty-printed, Brotli-encoded | 400/401/404/409/413/422/429
81
85
  // GET /api/bundles/<id>/pretty?other= → the files that changed between it and another bundle, both sides' pretty-printed,
@@ -89,6 +93,7 @@
89
93
  // GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
90
94
  // POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
91
95
  // GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
96
+ // GET /api/admin/bundles/<id>/usage → admin|manage the reports linked to it or declaring it, asked before a delete | 401/403/404
92
97
  // DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
93
98
  // POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
94
99
  // GET /api/admin/uploads/key → admin|manage public key for sealed upload bodies | 401/403
@@ -139,17 +144,18 @@ import { backfillFindingIds, reportEntries } from '@preventive/report'
139
144
  import type { IncomingMessage, ServerResponse } from 'node:http'
140
145
  import { pipeline } from 'node:stream/promises'
141
146
  import { setTimeout as delay } from 'node:timers/promises'
142
- import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, codeHeadStore, codeTreeStore, commitPullStore, githubTagStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
147
+ import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, bundleValidationStore, codeHeadStore, codeTreeStore, commitPullStore, githubTagStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
143
148
  issueListStore, lockfileReadStore,
144
149
  npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, pullListStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
145
150
  import { plainObject } from './npm-manifest.ts'
146
151
  import { type NpmDelta, keptNpmDeltas, npmDelta } from './npm-delta.ts'
147
152
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
148
- import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
153
+ import { auditCache, auditedKeys, lastListing } from './upstream-cache.ts'
149
154
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
150
155
  import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, newUploadKey, openSessionUpload, putUploadPart, readUpload, uploadPublicKey, validUpload, validUploadPart } from './uploads.ts'
151
156
  import { UPLOAD_SEAL_HEADER, maxSealedBytes } from '../common/managed/upload-seal.ts'
152
- import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
157
+ import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES, readBundleDetails } from './bundle-cache.ts'
158
+ import { BUNDLE_VALIDATION_READ, type BundleValidation, type PlannedBundle, validateAtCommit, validateDependencies, validationPlan } from './bundle-validation.ts'
153
159
  import type { CacheStorage } from './cache-storage.ts'
154
160
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
155
161
  import { type CatalogCommit, backfillBundleCommits, bundleCommits, cacheCommitDetails } from './bundle-commits.ts'
@@ -170,7 +176,7 @@ import { filterReportData } from '../common/managed/report-filter.ts'
170
176
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
171
177
  import { GITHUB_AGENTS } from '../common/managed/github-agents.ts'
172
178
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
173
- import { reportRepoGithub } from '@preventive/report'
179
+ import { reportRepoGithub, repoDirectory as stampedDirectory } from '@preventive/report'
174
180
  import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedReport } from '../common/managed/report-content.ts'
175
181
  import type { ReportSourcesCache } from './report-sources.ts'
176
182
  import { normalizeTeamPath } from './repo-path.ts'
@@ -231,7 +237,8 @@ import { type GithubAsked, type ListedLockfile, type LockfileRead, type Lockfile
231
237
  type PullDependencyStore, eachOf, lockfileFormat, pullDependenciesAt } from './pull-dependencies.ts'
232
238
  import { lockedAt, lockedIn, lockfilesAt } from './commit-lockfiles.ts'
233
239
  import { MAX_REQUIRED_REVIEWS, type RepoDependencyReview, SINCE_COMMIT } from './repository-dependency-reviews.ts'
234
- import { EVERY_LOCKFILE, heldTo, scopeOf } from './lockfile-scope.ts'
240
+ import { EVERY_LOCKFILE, heldTo, reviewedIn, scopeOf } from './lockfile-scope.ts'
241
+ import { type BundledVersion, bundleDependencies, installsBundle } from './bundle-dependency-reviews.ts'
235
242
  import { type ReviewBaselineStore, baselineFor, heldDependencies, reviewedDependencies, reviewedPackages } from './pull-dependency-reviews.ts'
236
243
  import { formattedPull, withFormatted } from './dependency-review-comments.ts'
237
244
  import type { ReviewedDependencies } from './reviewed-dependencies.ts'
@@ -359,7 +366,9 @@ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps,
359
366
  const storedIssues = await deps.db.listManagedIssues(ids)
360
367
  const issues = visibleManagedIssues(storedIssues, snapshot)
361
368
  const visibleIds = new Set(issues.map(issue => issue.findingId))
362
- const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
369
+ // A saved issue's duplicate is read with it, as a kept redirect is followed:
370
+ // its status even where only cached metadata can be shown.
371
+ const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl, issue.autoFixUrl, issue.duplicateOfUrl].filter(url => url !== null))])]
363
372
  await recheckTeam(deps.db, s.session.id, snapshot)
364
373
  const result = issues.length > 0
365
374
  ? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
@@ -1566,7 +1575,7 @@ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo:
1566
1575
 
1567
1576
  function scopeRepositoryContents(contents: RepositoryContents | null, virtualEntries: RepositoryEntry[] | null) {
1568
1577
  return contents && { ...contents, ...(virtualEntries ? {
1569
- entries: virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1578
+ entries: virtualEntries, limited: false, configEntryPoints: [], packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1570
1579
  } : {}) }
1571
1580
  }
1572
1581
 
@@ -3258,7 +3267,17 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
3258
3267
  (error: unknown) => { if (error instanceof GithubApiError && error.status === 404) return []; throw error }),
3259
3268
  }
3260
3269
  },
3261
- actions: githubActionsSource(user.token),
3270
+ // References resolved with the admin's token, and each one's advisories
3271
+ // asked as a bundle's `github` package's are (bundle-advisories.ts), one
3272
+ // `internal` of its repository alone, each bounded on its own.
3273
+ actions: { ...githubActionsSource(user.token), async advisories(name, asked, internal) {
3274
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
3275
+ const packages = [{ ecosystem: 'github' as const, name, versions: asked, internal }]
3276
+ const audited = await fetchBundleAdvisories(packages, signal, { debug: deps.config.debug, githubToken: user.token,
3277
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(packages, false)) })
3278
+ if (audited.status !== 200) throw new GithubApiError(502, audited.body.error)
3279
+ return audited.body
3280
+ } },
3262
3281
  actionFiles: scanReadStore(deps.db, 'action-files', SCAN_READ_KEPT_MS, deps.config.debug),
3263
3282
  actionLines: scanReadStore(deps.db, 'actions', RISK_KEPT_MS, deps.config.debug),
3264
3283
  teamScoped,
@@ -3291,10 +3310,11 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
3291
3310
  // and, for packages anyone may read, OSV: no repository's. Each lockfile's
3292
3311
  // audit is bounded on its own, for one npm doesn't answer to fail alone and
3293
3312
  // the scan to carry on.
3294
- async advisories(packages, scanning) {
3313
+ async advisories(asked, scanning) {
3295
3314
  const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
3296
- const audited = await fetchBundleAdvisories(packages.map(pkg => ({ ecosystem: 'npm' as const, ...pkg })), signal,
3297
- { debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug) })
3315
+ const packages = asked.map(pkg => ({ ecosystem: 'npm' as const, ...pkg }))
3316
+ const audited = await fetchBundleAdvisories(packages, signal,
3317
+ { debug: deps.config.debug, cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug, auditedKeys(packages, false)) })
3298
3318
  if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
3299
3319
  return audited.body
3300
3320
  },
@@ -3446,7 +3466,7 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
3446
3466
  if (res.destroyed || (needsGithub && !(await authorize()))) return
3447
3467
  const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
3448
3468
  debug: deps.config.debug, repoAdvisories, details, githubToken,
3449
- cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedRepos(inventory.packages, repoAdvisories)),
3469
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug, auditedKeys(inventory.packages, repoAdvisories)),
3450
3470
  })
3451
3471
  if (res.destroyed || !(await authorize())) return
3452
3472
  // Return just inventory and public advisories, never source or report data.
@@ -3457,6 +3477,163 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
3457
3477
  }
3458
3478
  }
3459
3479
 
3480
+ // Bundle `id` as `session` may read it now, else null, its refusal sent.
3481
+ async function readableBundle(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string) {
3482
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
3483
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
3484
+ if (!access.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
3485
+ return access.bundle
3486
+ }
3487
+
3488
+ // `body` told of bundle `record` where `session` may still read it, and it is
3489
+ // still where it was read: else refused, as one of another repository.
3490
+ async function tellOfBundle(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, record: ManagedBundle, body: unknown) {
3491
+ const now = await readableBundle(res, deps, session, id)
3492
+ if (!now) return
3493
+ if (now.repoId !== record.repoId || now.repoDirectory !== record.repoDirectory) sendJson(res, 409, { error: 'repository-changed' })
3494
+ else sendJson(res, 200, body)
3495
+ }
3496
+
3497
+ // GET /api/bundles/:id/dependency-review: what the npm package versions a
3498
+ // Stasis bundle holds (its advisory inventory's, bundle-advisory-inventory.ts)
3499
+ // want of review, where the repository it is attached to reviews
3500
+ // dependencies, as its Overview tells it (bundle-dependency-reviews.ts):
3501
+ // read as a default branch's are (branchDependencyAnswer), at the commit its
3502
+ // stamp names, of the lockfiles installing it there. None (`review: null`),
3503
+ // GitHub not asked, for a bundle of no repository, commit or npm versions,
3504
+ // one whose repository reviews none, and for a reader who may read only some
3505
+ // of it, as a team's Dependencies tab leaves such a one out; the bundle's
3506
+ // access checked again before it is told, and its repository not moved.
3507
+ async function handleBundleDependencyReview(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, session: ManagedSession, id: string) {
3508
+ const record = await readableBundle(res, deps, session, id)
3509
+ if (!record) return
3510
+ const none = () => { sendJson(res, 200, { review: null }) }
3511
+ const repoId = record.repoId
3512
+ if (record.kind !== 'stasis' || repoId === null || !deps.bundleCache) { none(); return }
3513
+ const setting = await deps.db.repoDependencyReview(repoId)
3514
+ if (!setting?.enabled) { none(); return }
3515
+ const access = await wholeRepositoryAccess(deps, cookie, repoId)
3516
+ if ('error' in access) {
3517
+ if (PARTIAL_ACCESS.has(access.error)) none()
3518
+ else sendRefusal(res, access)
3519
+ return
3520
+ }
3521
+ // Its inventory first: building it, where it isn't yet, keeps its summary too.
3522
+ let commit: string | undefined, inventory: BundleAdvisoryInventory | null | undefined
3523
+ try {
3524
+ inventory = await deps.bundleCache.advisoryInventory(record)
3525
+ commit = (await deps.bundleCache.summary(record))?.commit
3526
+ } catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
3527
+ if (inventory === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
3528
+ const bundled = (inventory?.packages ?? []).filter(pkg => pkg.ecosystem === 'npm').flatMap(pkg => pkg.versions.map(version => ({ name: pkg.name, version })))
3529
+ // Its commit as GitHub names one, of SHA-1.
3530
+ if (!commit || !/^[a-f\d]{40}$/u.test(commit) || bundled.length === 0) { none(); return }
3531
+ const read = await readWholeRepositoryOf(deps, access, (fullName, token, user) => bundleDependencyAnswer(deps, fullName, token, user, repoId, setting,
3532
+ { commit: commit!, directory: record.repoDirectory, bundled }), () => wholeRepositoryAccess(deps, cookie, repoId))
3533
+ if ('error' in read) { sendRefusal(res, read); return }
3534
+ await tellOfBundle(res, deps, session, id, record, read.value)
3535
+ }
3536
+
3537
+ // A bundle's answer, as GET /api/bundles/:id/dependency-review gives it, and
3538
+ // whether all of it was kept, GitHub asked for none of it: the versions
3539
+ // `bundled` (those of the bundle in `directory`, built from `commit`), as the
3540
+ // lockfiles there its review holds to lock them, judged by its review
3541
+ // (pull-dependency-reviews.ts), with how many it bundles (`versions`) and
3542
+ // those none of them lock (`unlocked`); none where its review holds to none
3543
+ // of the lockfiles installing it, there being some.
3544
+ async function bundleDependencyAnswer(deps: ManagedHttpDeps, fullName: string, token: string, user: StoredUser, repoId: number, setting: RepoDependencyReview,
3545
+ { commit, directory, bundled }: { commit: string; directory: string; bundled: BundledVersion[] }): Promise<{ value: object; kept: boolean }> {
3546
+ const asked: GithubAsked = { github: false }
3547
+ const reads = lockfileKept(deps, repoId)
3548
+ const installing = (await lockfilesAt(fullName, token, commit, reads, asked)).map(({ path }) => path).filter(path => installsBundle(path, directory))
3549
+ if (!reviewedIn(setting.lockfiles, installing)) return { value: { review: null }, kept: !asked.github }
3550
+ const read = await lockedAt(fullName, token, commit, reads, path => installsBundle(path, directory) && heldTo(setting.lockfiles, path), asked)
3551
+ const { told, ...bundle } = bundleDependencies(bundled, read, commit)
3552
+ const now = await deps.db.repoDependencyReviewState(repoId, user.id, reviewedPackages(told), setting)
3553
+ if (!now) return { value: { review: null }, kept: !asked.github }
3554
+ const state = { ...now, lockfiles: setting.lockfiles }
3555
+ const reviewed = deps.reviewedDependencies ?? null
3556
+ const { baseline, unread: baselineUnread, error } = reviewed ? { baseline: null, unread: [], error: null }
3557
+ : await baselineFor(fullName, token, repoId, state, baselineKept(deps), reads, asked)
3558
+ const { base: _base, ...answer } = await reviewedDependencies(told, state, { baseline, baselineUnread, baselineError: error, reviewed })
3559
+ return { value: { ...answer, ...bundle }, kept: !asked.github }
3560
+ }
3561
+
3562
+ // GET /api/bundles/:id/validation: the Stasis bundle's last validation its
3563
+ // reader may see, `{ validation }`; POST (`run`) validates it anew and keeps
3564
+ // it. Own code is checked only for a reader of all of its repository, and
3565
+ // told only while they still are; see doc/bundles.md#validation.
3566
+ const VALIDATIONS_AT_ONCE = 2
3567
+ const VALIDATION_MS = 240_000
3568
+ const validating = new Map<string, Promise<BundleValidation | Refusal>>()
3569
+ async function handleBundleValidation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, session: ManagedSession, id: string,
3570
+ run: boolean) {
3571
+ if (run && !await checkMutation(req, res, deps, cookie, { handed: true })) return
3572
+ const record = await readableBundle(res, deps, session, id)
3573
+ if (!record) return
3574
+ if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
3575
+ const whole = record.repoId === null ? null : await wholeRepositoryAccess(deps, cookie, record.repoId)
3576
+ if (whole && 'error' in whole && !PARTIAL_ACCESS.has(whole.error)) { sendRefusal(res, whole); return }
3577
+ const access = whole && !('error' in whole) ? whole : null
3578
+ const store = bundleValidationStore<BundleValidation>(deps.db, deps.config.debug)
3579
+ const keyOf = (own: boolean) => `${record.id}\0${record.integrity}\0${record.repoId ?? ''}\0${record.repoDirectory}\0${own ? 'own' : 'dependencies'}\0${BUNDLE_VALIDATION_READ}`
3580
+ // Why the reader of all of the repository may no longer be told its own
3581
+ // code, asked once that is read; null where they still may.
3582
+ const lostWhole = async () => {
3583
+ if (!access) return null
3584
+ const current = await wholeRepositoryAccess(deps, cookie, access.repo.repoId)
3585
+ return 'error' in current ? current : sameRepository(current.repo, access.repo) ? null : refusal(409, 'repository-changed')
3586
+ }
3587
+ if (!run) {
3588
+ const read = access ? await store.readFirst([keyOf(true)], [keyOf(false)]) : null
3589
+ let kept = read ? [...read.found.values()][0]?.value : (await store.read(keyOf(false)))?.value
3590
+ // The one that checked own code told only while its reader still reads all of the repository.
3591
+ if (read?.first && await lostWhole()) kept = (await store.read(keyOf(false)))?.value
3592
+ await tellOfBundle(res, deps, session, id, record, { validation: plainObject(kept) && typeof kept.status === 'string' ? kept : null })
3593
+ return
3594
+ }
3595
+ const key = keyOf(access !== null)
3596
+ const shared = access ? `${key}\0${access.user.id}` : key
3597
+ let job = validating.get(shared)
3598
+ if (!job) {
3599
+ if (validating.size >= VALIDATIONS_AT_ONCE) { sendJson(res, 429, { error: 'validation-busy' }); return }
3600
+ // Kept before it is told, for a reader asking next.
3601
+ job = bundleValidation(deps, cookie, record, access).then(async validation => {
3602
+ if (!('error' in validation)) await store.write(key, validation, validation.validatedAt)
3603
+ return validation
3604
+ }).finally(() => validating.delete(shared))
3605
+ validating.set(shared, job)
3606
+ }
3607
+ let validation: BundleValidation | Refusal
3608
+ try { validation = await job } catch (err) {
3609
+ if (deps.config.debug) console.warn('managed: bundle validation failed:', err)
3610
+ sendJson(res, 502, { error: 'validation-failed' }); return
3611
+ }
3612
+ if ('error' in validation) { sendRefusal(res, validation); return }
3613
+ const lost = validation.own.checked ? await lostWhole() : null
3614
+ if (lost) { sendRefusal(res, lost); return }
3615
+ await tellOfBundle(res, deps, session, id, record, { validation })
3616
+ }
3617
+
3618
+ // Bundle `record` validated (bundle-validation.ts): at the commit its stamp
3619
+ // names, with the GitHub access of `access`'s reader of all of its repository,
3620
+ // checked again after; else its dependencies alone, and why.
3621
+ async function bundleValidation(deps: ManagedHttpDeps, cookie: string | undefined, record: ManagedBundle, access: WholeAccess | null) {
3622
+ let details
3623
+ try { details = await readBundleDetails(record, deps.bundleStore) } catch { return refusal(422, 'bundle-unavailable') }
3624
+ const plan = details?.bundle ? validationPlan(details.bundle as PlannedBundle) : null
3625
+ if (!plan) return refusal(422, 'bundle-unavailable')
3626
+ const stamp = (details!.bundle as { repo?: { commit?: unknown; directory?: unknown } } | undefined)?.repo
3627
+ const commit = typeof stamp?.commit === 'string' && /^[a-f\d]{40}$/u.test(stamp.commit) ? stamp.commit : null
3628
+ // Where it was built, as its stamp says, else where it is kept.
3629
+ const directory = stampedDirectory(typeof stamp?.directory === 'string' ? stamp : { directory: record.repoDirectory })
3630
+ const options = { deadline: Date.now() + VALIDATION_MS }
3631
+ if (!access || !commit) return validateDependencies(plan, access ? 'no-commit' : record.repoId === null ? 'no-repository' : 'no-access', options)
3632
+ const read = await readWholeRepositoryOf(deps, access, async (fullName, token) => ({ value: await validateAtCommit(plan, { fullName, token, commit, directory }, options), kept: false }),
3633
+ () => wholeRepositoryAccess(deps, cookie, access.repo.repoId))
3634
+ return 'error' in read ? read : read.value
3635
+ }
3636
+
3460
3637
  // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
3461
3638
  // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
3462
3639
  // the upload repo picker.
@@ -3686,6 +3863,27 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
3686
3863
  try { await pipeline(stored.stream, res) } catch { res.destroy() }
3687
3864
  }
3688
3865
 
3866
+ // GET /api/admin/bundles/<id>/usage — the reports that name a bundle, read
3867
+ // before deleting it (admin|manage, as the delete): linked to it, so their
3868
+ // findings show its code, or declaring it in their bundleHashes without the
3869
+ // link. Reports the caller can't read are counted, never named. Read-only, so
3870
+ // no CSRF. 404 when the caller can't manage the bundle.
3871
+ async function handleBundleUsage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
3872
+ const s = await readManageSession(res, deps, cookie, { handed: true })
3873
+ if (s == null) return
3874
+ const bundle = await deps.db.getBundle(id)
3875
+ if (!bundle || (s.user.role !== 'admin' && !await deps.db.userCanReadBundle(s.user.id, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
3876
+ // Each report's readability is read with it, as canViewReport tells a
3877
+ // manager's: one statement however many reports use the bundle.
3878
+ const reports: { id: string; filename: string; linked: boolean }[] = []
3879
+ let otherReports = 0
3880
+ for (const report of await deps.db.listReportsForBundle(id, bundle.integrity, s.user.role === 'admin' ? undefined : s.user.id)) {
3881
+ if (report.readable) reports.push({ id: report.id, filename: report.filename, linked: report.bundleId === id })
3882
+ else otherReports++
3883
+ }
3884
+ sendJson(res, 200, { bundleId: id, reports, otherReports })
3885
+ }
3886
+
3689
3887
  // DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
3690
3888
  // same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
3691
3889
  // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
@@ -5397,6 +5595,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5397
5595
  await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
5398
5596
  return
5399
5597
  }
5598
+ const bundleValidationRoute = /^\/api\/bundles\/([a-f\d-]{36})\/validation$/iu.exec(path)
5599
+ if (bundleValidationRoute) {
5600
+ if (method !== 'GET' && method !== 'POST') { send405(res, 'GET, POST'); return }
5601
+ await handleBundleValidation(req, res, deps, cookie, workspaceSession!.session, bundleValidationRoute[1]!, method === 'POST')
5602
+ return
5603
+ }
5604
+ const bundleReview = /^\/api\/bundles\/([a-f\d-]{36})\/dependency-review$/iu.exec(path)
5605
+ if (bundleReview) {
5606
+ if (method !== 'GET') { send405(res, 'GET'); return }
5607
+ await handleBundleDependencyReview(res, deps, cookie, workspaceSession!.session, bundleReview[1]!)
5608
+ return
5609
+ }
5400
5610
  const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
5401
5611
  if (bundleRead) {
5402
5612
  const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
@@ -5436,6 +5646,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5436
5646
  if (method !== 'POST') { send405(res, 'POST'); return }
5437
5647
  await handleSetBundleVisible(req, res, deps, cookie); return
5438
5648
  }
5649
+ const bundleUsage = /^\/api\/admin\/bundles\/([a-f\d-]{36})\/usage$/iu.exec(path)
5650
+ if (bundleUsage) {
5651
+ if (method !== 'GET') { send405(res, 'GET'); return }
5652
+ await handleBundleUsage(res, deps, cookie, bundleUsage[1]!); return
5653
+ }
5439
5654
  if (path.startsWith(BUNDLE_PREFIX)) {
5440
5655
  const id = path.slice(BUNDLE_PREFIX.length)
5441
5656
  if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
@@ -9,6 +9,7 @@ import { runReapers, withReap } from '../server-common/reap.ts'
9
9
  import { startServer } from '../server-common/standalone.ts'
10
10
  import { initializeApp } from '../server-common/initialize.ts'
11
11
  import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
+ import type { ManagedDb } from './db.ts'
12
13
  import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
14
  import { setNpmTarballCache } from './npm-loads.ts'
14
15
  import { assembledReviewedDependencies } from './reviewed-dependencies.ts'
@@ -21,6 +22,17 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
21
22
  // without a timer or cron. Failed sweeps retry on later traffic after a minute.
22
23
  const REAP_INTERVAL_MS = 3_600_000
23
24
  const REAP_RETRY_MS = 60_000
25
+ // A team's stored catalog summary (team-app-summaries.ts) not stored again in
26
+ // this long: one still asked for is worked out again once.
27
+ const TEAM_APP_SUMMARY_KEPT_MS = 30 * 86_400_000
28
+
29
+ // Rows kept past their time: expired sessions, how many, and teams' catalog
30
+ // summaries stored too long ago.
31
+ async function reapRows(db: Pick<ManagedDb, 'deleteExpiredSessions' | 'deleteTeamAppSummaries'>): Promise<number> {
32
+ const now = Date.now()
33
+ const [sessions] = await Promise.all([db.deleteExpiredSessions(now), db.deleteTeamAppSummaries(now - TEAM_APP_SUMMARY_KEPT_MS)])
34
+ return sessions
35
+ }
24
36
 
25
37
  // `ui: false` serves only the API: `out/` is never read.
26
38
  type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
@@ -79,7 +91,7 @@ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options
79
91
  let succeeded = false
80
92
  try {
81
93
  await runReapers({
82
- sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
94
+ sessions: async () => { sessions = await reapRows(db) },
83
95
  ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
84
96
  ...('reapPrettyCopies' in storage ? { prettyCopies: () => storage.reapPrettyCopies(maintenance.signal) } : {}),
85
97
  ...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
@@ -1,10 +1,13 @@
1
1
  import type { ManagedSql } from './sql.ts'
2
2
  import type { TeamReportAccessSnapshot } from './db.ts'
3
- import { parseGithubPrUrl } from '../common/github-pr.ts'
3
+ import { parseGithubIssueUrl, parseGithubPrUrl } from '../common/github-pr.ts'
4
4
 
5
5
  // One immutable reference per finding, shared across reports and teams. Keep
6
6
  // reservations across crashes/uncertain GitHub responses to prevent duplicates.
7
- // No triage/import/admin patch accepts these fields.
7
+ // No triage/import/admin patch accepts these fields. What a lookup derives of
8
+ // the issue (github-issue-links.ts) sits beside it, never in its place: the
9
+ // issue it was closed as a duplicate of, kept as a redirect is, and the pull
10
+ // request fixing either, both as last checked (auto_fix_checked_at).
8
11
  export const MANAGED_ISSUE_SCHEMA = `
9
12
  CREATE TABLE IF NOT EXISTS managed_finding_issue (
10
13
  finding_id TEXT PRIMARY KEY,
@@ -14,6 +17,7 @@ CREATE TABLE IF NOT EXISTS managed_finding_issue (
14
17
  issue_url TEXT,
15
18
  auto_fix_url TEXT,
16
19
  auto_fix_checked_at INTEGER,
20
+ duplicate_of_url TEXT,
17
21
  created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
18
22
  created_at INTEGER NOT NULL
19
23
  ) STRICT;
@@ -26,16 +30,20 @@ export interface ManagedIssue {
26
30
  issueUrl: string | null
27
31
  autoFixUrl: string | null
28
32
  autoFixCheckedAt: number | null
33
+ duplicateOfUrl: string | null
29
34
  createdBy: string | null
30
35
  createdAt: number
31
36
  }
37
+ // What a lookup derives of a saved issue: the pull request fixing it, and the
38
+ // issue it was closed as a duplicate of (followed to the last such one).
39
+ export type IssueTracking = { autoFix: string | null; duplicateOf: string | null }
32
40
  export interface ManagedIssueStore {
33
41
  getManagedIssue(findingId: string): Promise<ManagedIssue | null>
34
42
  listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
35
- claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
43
+ claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt' | 'duplicateOfUrl'>): Promise<boolean>
36
44
  finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
37
45
  releaseManagedIssue(findingId: string, requestId: string): Promise<void>
38
- setManagedIssueAutoFix(findingId: string, issueUrl: string, previous: string | null, next: string | null, checkedAt: number): Promise<boolean>
46
+ setManagedIssueTracking(findingId: string, issueUrl: string, previous: IssueTracking, next: IssueTracking, checkedAt: number): Promise<boolean>
39
47
  }
40
48
 
41
49
  // A repeated finding ID never grants access to another team's repository.
@@ -43,21 +51,26 @@ export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamRepor
43
51
  if (snapshot.user.id.startsWith('share:')) return []
44
52
  const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
45
53
  const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
46
- return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue => {
47
- const ref = parseGithubPrUrl(issue.autoFixUrl)
48
- return { ...issue, autoFixUrl: ref && names.has(ref.repo.toLowerCase()) ? issue.autoFixUrl : null }
49
- })
54
+ const inTeam = (url: string | null, parse: typeof parseGithubPrUrl) => {
55
+ const ref = parse(url)
56
+ return ref && names.has(ref.repo.toLowerCase()) ? url : null
57
+ }
58
+ return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue =>
59
+ ({ ...issue, autoFixUrl: inTeam(issue.autoFixUrl, parseGithubPrUrl), duplicateOfUrl: inTeam(issue.duplicateOfUrl, parseGithubIssueUrl) }))
50
60
  }
51
61
  export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
52
62
  const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
53
- issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
63
+ issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, duplicate_of_url AS duplicateOfUrl,
64
+ created_by AS createdBy, created_at AS createdAt`
54
65
  const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
55
66
  const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
56
- // A lookup's derived Fix replaces the one it read, unless one started later
57
- // changed it since: the check time, which only changes stamp
58
- // (github-issue-links.ts issueUpdates), orders them.
59
- const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
67
+ // A lookup's derived Fix and duplicate replace those it read, unless one
68
+ // started later changed them since: the check time, which only changes
69
+ // stamp (github-issue-links.ts issueUpdates), orders them. The issue's own
70
+ // URL is never written here.
71
+ const tracking = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, duplicate_of_url = ?, auto_fix_checked_at = ?
60
72
  WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
73
+ AND COALESCE(duplicate_of_url, '') = COALESCE(?, '')
61
74
  AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
62
75
  const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
63
76
  VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
@@ -66,8 +79,9 @@ export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
66
79
  return {
67
80
  async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
68
81
  async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
69
- async setManagedIssueAutoFix(findingId, issueUrl, previous, next, checkedAt) {
70
- return Number((await autoFix.run(next, checkedAt, findingId, issueUrl, previous, checkedAt)).changes) > 0
82
+ async setManagedIssueTracking(findingId, issueUrl, previous, next, checkedAt) {
83
+ return Number((await tracking.run(next.autoFix, next.duplicateOf, checkedAt, findingId, issueUrl,
84
+ previous.autoFix, previous.duplicateOf, checkedAt)).changes) > 0
71
85
  },
72
86
  async claimManagedIssue(issue) {
73
87
  return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
@@ -286,10 +286,11 @@ export async function npmCommitTags(repo: string, sha: string, version: string,
286
286
  // An advisory on the package, with the versions it covers as their indexes in
287
287
  // the list it was asked with: npm's (`registry`), or one its repository
288
288
  // publishes on GitHub before GitHub reviews it into npm's (`repository`); its
289
- // text (markdown) where its source has one.
289
+ // text (markdown) where its source has one; `malware` where upstream tells it
290
+ // is of malware, any of its rows.
290
291
  export interface NpmAdvisory {
291
292
  id: string; source: Advisory['source']; ghsa?: string; url?: string; title?: string; severity?: string; cvss?: number; cwe: string[]; range?: string
292
- details?: string; affected: number[]
293
+ details?: string; malware?: true; affected: number[]
293
294
  }
294
295
 
295
296
  // `versions` those `affected` indexes; `repository` false where the
@@ -377,7 +378,7 @@ async function askAdvisories(name: string, versions: string[], { githubToken, re
377
378
  const own: NpmAdvisory = {
378
379
  id: row.id, source: row.source, ...row.ghsa && { ghsa: row.ghsa }, ...row.url && { url: row.url }, ...row.title && { title: row.title },
379
380
  ...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
+ ...row.details && { details: row.details }, ...row.malware && { malware: true }, affected,
381
382
  }
382
383
  const known = merged.get(row.id)
383
384
  if (!known) {
@@ -227,3 +227,7 @@ export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): Kept
227
227
  // what their reviews are held to.
228
228
  export const lockfileReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles', debug = false): KeptStore<T> =>
229
229
  keptStore<T>(db, `repository/review-${type}`, HISTORY_PAGE_KEPT_MS, debug)
230
+ // A bundle's validations (bundle-validation.ts), by the bundle, where it is,
231
+ // what its reader could check and the rules they were made by: kept a month
232
+ // after they were made, as what they check never changes.
233
+ export const bundleValidationStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'bundles/validations', HISTORY_PAGE_KEPT_MS, debug)