@preventive/triage 1.0.0-alpha.52 → 1.0.0-alpha.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/bundle-compare-diff.js +28 -9
- package/common/bundle-compare-inputs.d.ts +1 -0
- package/common/bundle-compare-inputs.js +91 -15
- package/common/bundle-comparison.js +14 -7
- package/common/default-scan-models.ts +3 -0
- package/common/dependency-reviews.d.ts +1 -1
- package/common/dependency-reviews.js +1 -1
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +27 -15
- package/common/managed/team-app.d.ts +9 -0
- package/common/managed/team-app.js +59 -6
- package/common/workspace-app.js +38 -6
- package/out/activity.js +4 -4
- package/out/chunk-4EMM6UCD.js +1 -0
- package/out/chunk-4ISO5F7Y.js +7 -0
- package/out/chunk-4XIH7YTX.js +1 -0
- package/out/chunk-6W5BMATD.js +1 -0
- package/out/{chunk-WFYLMF6Z.js → chunk-74AEKRES.js} +1 -1
- package/out/chunk-DEVAORP2.js +1 -0
- package/out/{chunk-KOGNQDSJ.js → chunk-DTM47KGS.js} +1 -1
- package/out/chunk-E3LFSUDJ.js +9 -0
- package/out/chunk-FHIVVVUA.js +6 -0
- package/out/chunk-G3LY2FV4.js +1 -0
- package/out/chunk-HAZKDK4M.js +4 -0
- package/out/chunk-HOB3YPS5.js +1 -0
- package/out/chunk-I47TYPXO.js +52 -0
- package/out/chunk-J7A5OSZE.js +244 -0
- package/out/chunk-JFL7WLRV.js +1 -0
- package/out/chunk-JMG5DXII.js +2 -0
- package/out/chunk-JOXLZXMI.js +1 -0
- package/out/chunk-L4CTEMRG.js +2 -0
- package/out/chunk-M37X4LCF.js +14 -0
- package/out/chunk-M3Y6SLC4.js +1 -0
- package/out/chunk-NXATZJQ2.js +1 -0
- package/out/{chunk-MYGJBYYJ.js → chunk-ORIP5INC.js} +1 -1
- package/out/chunk-PAD2JHZT.js +1 -0
- package/out/chunk-QMVOEBWC.js +13 -0
- package/out/{chunk-SXV2T5C4.js → chunk-QRKFPLA4.js} +1 -1
- package/out/chunk-RRBIJL2B.js +1 -0
- package/out/{chunk-GDPCLSOS.js → chunk-SYXT3SFP.js} +1 -1
- package/out/chunk-TOXMUOIW.js +5 -0
- package/out/chunk-UBOCLLF2.js +1 -0
- package/out/chunk-UCP3JRE2.js +1 -0
- package/out/{chunk-NCN3M5EP.js → chunk-VC72ZO3P.js} +2 -2
- package/out/chunk-VRX2APNA.js +9 -0
- package/out/{chunk-IMM274IW.js → chunk-VZMELPVL.js} +4 -3
- package/out/{chunk-CHRBUGVN.js → chunk-WHVXTJHK.js} +1 -1
- package/out/chunk-WMFDQS2L.js +11 -0
- package/out/chunk-WNSVBOSO.js +41 -0
- package/out/chunk-WREU7JAG.js +1 -0
- package/out/chunk-Y5ZHFNBD.js +2 -0
- package/out/chunk-YF4M5OCI.js +1 -0
- package/out/chunk-Z667U2UJ.js +1 -0
- package/out/chunk-ZQHLECTI.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +27 -26
- package/out/compare.js +14 -14
- package/out/dialogs.js +36 -51
- package/out/graph.js +17 -15
- package/out/index.html +26 -10
- package/out/prism.js +1 -1
- package/out/scan.js +2 -2
- package/out/terminal.js +232 -232
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +12 -7
- package/risk/actions/resolve.js +36 -5
- package/risk/index.d.ts +20 -2
- package/risk/npm/malware.js +6 -4
- package/risk/npm/registry.js +8 -6
- package/server-managed/bundle-advisories.ts +56 -74
- package/server-managed/bundle-build-worker.js +61 -4
- package/server-managed/bundle-build.ts +13 -4
- package/server-managed/bundle-dependency-reviews.ts +74 -0
- package/server-managed/bundle-validation.ts +32 -0
- package/server-managed/db-methods.ts +51 -16
- package/server-managed/db-neon.ts +18 -2
- package/server-managed/db.ts +11 -2
- package/server-managed/github-issue-links.ts +151 -47
- package/server-managed/github-pulls.ts +34 -15
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/http.ts +227 -12
- package/server-managed/index.ts +13 -1
- package/server-managed/managed-issues.ts +29 -15
- package/server-managed/npm-insights.ts +4 -3
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/package-entry-points.ts +37 -9
- package/server-managed/pretty-print.ts +7 -5
- package/server-managed/public-workspace.ts +2 -2
- package/server-managed/repository-actions.ts +7 -4
- package/server-managed/repository-browser.ts +11 -4
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +11 -2
- package/server-managed/repository-risk-audit.ts +4 -5
- package/server-managed/repository-risk.ts +4 -4
- package/server-managed/sql.ts +2 -1
- package/server-managed/stasis-config.ts +46 -0
- package/server-managed/team-app-summaries.ts +54 -0
- package/server-managed/team-app.ts +52 -31
- package/server-managed/team-reports.ts +34 -13
- package/server-managed/upstream-cache.ts +41 -32
- package/server-managed/workspace-shares.ts +3 -1
- package/out/chunk-2ZDZWLFY.js +0 -1
- package/out/chunk-7VAWINQF.js +0 -6
- package/out/chunk-FMXWXDR4.js +0 -36
- package/out/chunk-IZ7POWI3.js +0 -18
- package/out/chunk-LJ7XRXFU.js +0 -52
- package/out/chunk-OGAYMEER.js +0 -232
- package/out/chunk-QB4CBOX2.js +0 -1
- package/out/chunk-TQJDYHVE.js +0 -2
- package/out/chunk-VH4XMZIW.js +0 -2
- package/out/chunk-WYLQWYLA.js +0 -1
|
@@ -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)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// A default branch's history read by when its commits were made, for the
|
|
2
|
+
// Pulse (repository-pulse.ts yearCommits): where the pages kept from a head
|
|
3
|
+
// run out, the rest of the time it needs read at once, in windows of time
|
|
4
|
+
// (GitHub's `history(since:, until:)`), not a page after another, each page
|
|
5
|
+
// waiting on the last; and the pages GitHub would list from the commits read
|
|
6
|
+
// (git log) kept by their tops, as reading a page at a time keeps them, so a
|
|
7
|
+
// walk later, from a head moved on or in the Commits tab, reads them kept.
|
|
8
|
+
import { linkNoreplyUsers } from './github-accounts.ts'
|
|
9
|
+
import { githubGraphql } from './github-graphql.ts'
|
|
10
|
+
import { COMMIT_FRAGMENT, type GraphCommit, HISTORY_PAGE, type HistoryCommit, type HistoryPages, PAGES_PARALLEL, PAGES_PER_QUERY, commitActors, graphCommit,
|
|
11
|
+
inBatches, variablesOf } from './repository-history.ts'
|
|
12
|
+
|
|
13
|
+
// Windows a read is split into at most, each meant to hold as many commits
|
|
14
|
+
// as WINDOW_COMMITS, a page's worth with room to spare, as often as the
|
|
15
|
+
// commits read before it were made, as far back as they reach, the rest left
|
|
16
|
+
// to the next read; pages read of them at most a read, those over going on
|
|
17
|
+
// where a window holds more, as many times as MAX_WAVES, each waiting on the
|
|
18
|
+
// last; and pages kept of what was read at most.
|
|
19
|
+
const MAX_WINDOWS = 40
|
|
20
|
+
const WINDOW_COMMITS = 70
|
|
21
|
+
const WINDOW_PAGES = 50
|
|
22
|
+
const MAX_WAVES = 5
|
|
23
|
+
const MAX_KEPT = 500
|
|
24
|
+
const KEPT_AT_ONCE = 10
|
|
25
|
+
const WINDOWS_AFTER = 2
|
|
26
|
+
const HOUR_MS = 60 * 60_000
|
|
27
|
+
|
|
28
|
+
// Each window's commits from `$head`, `w<n>` the `n`th's: those made since
|
|
29
|
+
// `$s<n>` (none, any time) and until `$u<n>`, after `$a<n>`.
|
|
30
|
+
const windowsQuery = (count: number) => `query($owner: String!, $name: String!, $head: GitObjectID!, ${variablesOf('s', count, 'GitTimestamp')}, ${
|
|
31
|
+
variablesOf('u', count, 'GitTimestamp!')}, ${variablesOf('a', count, 'String')}) {
|
|
32
|
+
repository(owner: $owner, name: $name) {
|
|
33
|
+
object(oid: $head) { ... on Commit {
|
|
34
|
+
${Array.from({ length: count }, (_, at) => `w${at}: history(first: ${HISTORY_PAGE}, since: $s${at}, until: $u${at}, after: $a${at}) {
|
|
35
|
+
pageInfo { hasNextPage endCursor } nodes { ...historyCommit } }`).join('\n ')}
|
|
36
|
+
} }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
${COMMIT_FRAGMENT}`
|
|
40
|
+
|
|
41
|
+
type Window = { since: number | null; until: number; after: string | null; oldest?: number }
|
|
42
|
+
type WindowPage = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphCommit[] } | null
|
|
43
|
+
type Windows = { repository?: { object?: Record<string, WindowPage> | null } | null } | null
|
|
44
|
+
// A time as GitHub takes it, to the second: a window's start rounded down,
|
|
45
|
+
// its end up, so that those beside each other overlap rather than part.
|
|
46
|
+
const stamp = (at: number, up: boolean) => new Date((up ? Math.ceil : Math.floor)(at / 1000) * 1000).toISOString()
|
|
47
|
+
|
|
48
|
+
// The commits from `head` made from `low` to `high`, as often as `perMs` (commits
|
|
49
|
+
// a millisecond) says they are made, as many windows as MAX_WINDOWS reach
|
|
50
|
+
// down from `high`, read at once (inBatches), the latest first, each paged on
|
|
51
|
+
// as far as it goes, as many pages as WINDOW_PAGES; and, those reaching
|
|
52
|
+
// `low`, a page of those made before it besides, where a walk reading them
|
|
53
|
+
// goes on from. Since when every one is read (`through`: where the windows
|
|
54
|
+
// reach, each read whole, else, of the latest not, just after the earliest
|
|
55
|
+
// made of those it read, as GitHub lists them the latest made first, those
|
|
56
|
+
// made together left out; or its end, none read), and how many pages were
|
|
57
|
+
// read (`read`).
|
|
58
|
+
export async function readWindows(fullName: string, token: string, head: string, low: number, high: number, perMs: number, fetchImpl: typeof fetch) {
|
|
59
|
+
const [owner, name] = fullName.split('/') as [string, string]
|
|
60
|
+
const bottom = Math.max(low, high - MAX_WINDOWS * WINDOW_COMMITS / perMs)
|
|
61
|
+
const count = Math.min(MAX_WINDOWS, Math.max(1, Math.ceil((high - bottom) * perMs / WINDOW_COMMITS)))
|
|
62
|
+
const width = (high - bottom) / count
|
|
63
|
+
let open: Window[] = [...Array.from({ length: count }, (_, at) => ({ since: high - (at + 1) * width, until: high - at * width, after: null })),
|
|
64
|
+
...bottom > low ? [] : [{ since: null, until: low - 1000, after: null }]]
|
|
65
|
+
const commits = new Map<string, HistoryCommit>()
|
|
66
|
+
let [read, waves] = [0, 0]
|
|
67
|
+
while (open.length > 0 && read < WINDOW_PAGES && waves++ < MAX_WAVES) {
|
|
68
|
+
const wave = open.slice(0, WINDOW_PAGES - read)
|
|
69
|
+
const next: Window[] = []
|
|
70
|
+
read += wave.length
|
|
71
|
+
await inBatches(wave, PAGES_PER_QUERY, PAGES_PARALLEL, async batch => {
|
|
72
|
+
const data = await githubGraphql(windowsQuery(batch.length), { owner, name, head, ...Object.fromEntries(batch.flatMap((window, at) => [
|
|
73
|
+
[`s${at}`, window.since === null ? null : stamp(window.since, false)], [`u${at}`, stamp(window.until, true)], [`a${at}`, window.after]])) }, token, fetchImpl) as Windows
|
|
74
|
+
const pages = batch.map((_, at) => data?.repository?.object?.[`w${at}`] ?? null)
|
|
75
|
+
await linkNoreplyUsers(pages.flatMap(page => page?.nodes ?? []).flatMap(commitActors), token, fetchImpl)
|
|
76
|
+
for (const [at, window] of batch.entries()) {
|
|
77
|
+
let oldest = window.oldest ?? Number.POSITIVE_INFINITY
|
|
78
|
+
for (const commit of (pages[at]?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])) {
|
|
79
|
+
if (!commits.has(commit.sha)) commits.set(commit.sha, commit)
|
|
80
|
+
oldest = Math.min(oldest, commit.committedAt ?? Number.POSITIVE_INFINITY)
|
|
81
|
+
}
|
|
82
|
+
const info = pages[at]?.pageInfo
|
|
83
|
+
if (window.since !== null && info?.hasNextPage === true && typeof info.endCursor === 'string') next.push({ ...window, after: info.endCursor, oldest })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
open = [...next, ...open.slice(wave.length)]
|
|
87
|
+
}
|
|
88
|
+
const through = Math.max(bottom, ...open.flatMap(window => window.since === null ? [] : [Number.isFinite(window.oldest) ? window.oldest! + 1 : window.until]))
|
|
89
|
+
return { commits: [...commits.values()], through, read }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The page GitHub would list from `top` (git log: of those reached, the
|
|
93
|
+
// latest made first, of those made together the first reached) of `known`
|
|
94
|
+
// commits, every one made since `low` known; none where it would list one not
|
|
95
|
+
// known, or one made before `low` while one not known waits beside it.
|
|
96
|
+
export function gitLogPage(top: string, known: Map<string, HistoryCommit>, low: number): HistoryCommit[] | null {
|
|
97
|
+
if (!known.has(top)) return null
|
|
98
|
+
const page: HistoryCommit[] = []
|
|
99
|
+
const reached = new Set<string>()
|
|
100
|
+
const waiting: HistoryCommit[] = []
|
|
101
|
+
let unknown = 0
|
|
102
|
+
const reach = (sha: string) => {
|
|
103
|
+
if (reached.has(sha)) return
|
|
104
|
+
reached.add(sha)
|
|
105
|
+
const commit = known.get(sha)
|
|
106
|
+
if (!commit || commit.committedAt === null) { unknown++; return }
|
|
107
|
+
const at = waiting.findIndex(other => other.committedAt! < commit.committedAt!)
|
|
108
|
+
waiting.splice(at < 0 ? waiting.length : at, 0, commit)
|
|
109
|
+
}
|
|
110
|
+
reach(top)
|
|
111
|
+
while (page.length < HISTORY_PAGE && waiting.length > 0) {
|
|
112
|
+
const commit = waiting.shift()!
|
|
113
|
+
if (commit.committedAt! < low && unknown > 0) return null
|
|
114
|
+
page.push(commit)
|
|
115
|
+
for (const parent of commit.parents) reach(parent)
|
|
116
|
+
}
|
|
117
|
+
return page.length < HISTORY_PAGE && unknown > 0 ? null : page
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The pages from each of `tops` GitHub would list of `known` commits
|
|
121
|
+
// (gitLogPage), kept by their tops, KEPT_AT_ONCE at a time, and from the
|
|
122
|
+
// parents of those they list that none of `listed` is nor any kept lists, on
|
|
123
|
+
// till none is left, as many as MAX_KEPT: as a walk reading pages goes on to
|
|
124
|
+
// them.
|
|
125
|
+
export async function keepPages(tops: string[], known: Map<string, HistoryCommit>, listed: Set<string>, low: number, pages: HistoryPages) {
|
|
126
|
+
const covered = new Set(listed)
|
|
127
|
+
const kept: [string, HistoryCommit[]][] = []
|
|
128
|
+
const waiting = [...tops]
|
|
129
|
+
while (waiting.length > 0 && kept.length < MAX_KEPT) {
|
|
130
|
+
const top = waiting.shift()!
|
|
131
|
+
if (covered.has(top)) continue
|
|
132
|
+
const page = gitLogPage(top, known, low)
|
|
133
|
+
if (!page) continue
|
|
134
|
+
kept.push([top, page])
|
|
135
|
+
for (const commit of page) covered.add(commit.sha)
|
|
136
|
+
for (const commit of page) for (const parent of commit.parents) if (!covered.has(parent) && known.has(parent)) waiting.push(parent)
|
|
137
|
+
}
|
|
138
|
+
for (let at = 0; at < kept.length; at += KEPT_AT_ONCE) await Promise.all(kept.slice(at, at + KEPT_AT_ONCE).map(([top, page]) => pages.write(top, page)))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// What a walk from `head` (yearCommits) reads next of `starts`: those a page
|
|
142
|
+
// is kept from (`batch`), where any is, or all of them, a page at a time, as
|
|
143
|
+
// long as it has read GitHub fewer times than WINDOWS_AFTER (`queries`), a
|
|
144
|
+
// head moved on soon reaching the pages kept from where it was; else, none
|
|
145
|
+
// kept, those made from
|
|
146
|
+
// `low` to `high` read at once (readWindows), as often as those `found`
|
|
147
|
+
// were made, told it (`found`), and the pages from `starts` on kept of them,
|
|
148
|
+
// as far down as every one is read (keepPages), the queries that took
|
|
149
|
+
// (`queries`, PAGES_PER_QUERY pages each, as many at once as PAGES_PARALLEL
|
|
150
|
+
// a query of its) and none to read (`windowed`); where the windows can't be
|
|
151
|
+
// read, all of them, a page at a time (`windowed` all the same).
|
|
152
|
+
export async function windowsOn(fullName: string, token: string, head: string, starts: string[], found: Map<string, HistoryCommit>, low: number, high: number,
|
|
153
|
+
pages: HistoryPages, fetchImpl: typeof fetch, queries: number): Promise<{ batch: string[]; queries: number; windowed: boolean }> {
|
|
154
|
+
const kept = await pages.read(starts)
|
|
155
|
+
if (kept.size > 0 || queries < WINDOWS_AFTER || !(high > low) || !Number.isFinite(high)) {
|
|
156
|
+
return { batch: kept.size > 0 ? starts.filter(sha => kept.has(sha)) : starts, queries: 0, windowed: false }
|
|
157
|
+
}
|
|
158
|
+
let [newest, oldest, dated] = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, 0]
|
|
159
|
+
for (const { committedAt } of found.values()) {
|
|
160
|
+
if (committedAt === null) continue
|
|
161
|
+
[newest, oldest, dated] = [Math.max(newest, committedAt), Math.min(oldest, committedAt), dated + 1]
|
|
162
|
+
}
|
|
163
|
+
// From the one commit the walk goes on from, where there is one, none of
|
|
164
|
+
// those read listed again; else from the head.
|
|
165
|
+
const read = await readWindows(fullName, token, starts.length === 1 ? starts[0]! : head, low, high, dated / Math.max(newest - oldest, HOUR_MS), fetchImpl)
|
|
166
|
+
.catch((err: unknown) => { console.warn('managed: history windows read failed:', err); return null })
|
|
167
|
+
if (!read) return { batch: starts, queries: 0, windowed: true }
|
|
168
|
+
const listed = new Set(found.keys())
|
|
169
|
+
for (const commit of read.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
|
|
170
|
+
await keepPages(starts, found, listed, read.through, pages)
|
|
171
|
+
return { batch: [], queries: Math.ceil(read.read / (PAGES_PER_QUERY * PAGES_PARALLEL)), windowed: true }
|
|
172
|
+
}
|
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) } : {}),
|