@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.
- package/common/bundle-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +1 -1
- package/common/managed/finding-detail.js +7 -7
- package/common/managed/team-app.d.ts +7 -0
- package/common/managed/team-app.js +31 -7
- package/common/workspace-app.js +19 -7
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-JEFZTVNX.js → chunk-VC72ZO3P.js} +1 -1
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-NE2UGKRJ.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-YA4ETCFU.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +11 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +27 -7
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-TSIFIFBX.js +0 -232
- package/out/chunk-VH4XMZIW.js +0 -2
- 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
|
|
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 (
|
|
186
|
-
const publicRefreshes = new Set(
|
|
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 <
|
|
192
|
-
await Promise.all(
|
|
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)
|
package/server-managed/http.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
3313
|
+
async advisories(asked, scanning) {
|
|
3295
3314
|
const signal = AbortSignal.any([scanning, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)])
|
|
3296
|
-
const
|
|
3297
|
-
|
|
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,
|
|
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 }
|
package/server-managed/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
const ref =
|
|
48
|
-
return
|
|
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,
|
|
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
|
|
57
|
-
// changed
|
|
58
|
-
// (github-issue-links.ts issueUpdates), orders them.
|
|
59
|
-
|
|
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
|
|
70
|
-
return Number((await
|
|
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)
|