@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.39
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/README.md +16 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +6 -2
- package/common/bundle-metadata.js +63 -20
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-source-links.js +7 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +3 -1
- package/common/bundle-sources.js +47 -19
- package/common/code-language.js +16 -2
- package/common/default-scan-models.ts +2 -1
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/github-pr.ts +1 -1
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +1 -5
- package/common/managed/routes.js +123 -12
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +1 -1
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-45WIN3SW.js +1 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-6C76GAXK.js +1 -0
- package/out/chunk-7ZKDKGAH.js +1 -0
- package/out/chunk-ABW5FVSN.js +1 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-F4OMDNEY.js +1 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GMFXFEUA.js +2 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-HIV7SVI7.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-JVBB4LZX.js +1 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-QSLWGJBQ.js +223 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-TINZZKP5.js +33 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-XNGEQO7Y.js +2 -0
- package/out/chunk-XRWAV3DX.js +7 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZPD5R5RP.js +3 -0
- package/out/client-managed-import.js +3 -87
- package/out/client-managed.js +26 -338
- package/out/client-sync.js +8 -14
- package/out/graph.js +17 -30
- package/out/index.html +34 -0
- package/out/prism.js +6 -2
- package/out/terminal.js +241 -257
- package/out/view.css +1 -1
- package/out/view.js +76 -520
- package/package.json +89 -7
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-e2e/http.ts +7 -7
- package/server-e2e/npm-proxy.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
- package/server-e2e/objstore/handlers.ts +1 -1
- package/server-e2e/objstore/tokens.ts +1 -1
- package/server-e2e/sign.ts +1 -1
- package/server-managed/bundle-advisories.ts +74 -12
- package/server-managed/bundle-cache.ts +37 -4
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-summary-cache.ts +24 -10
- package/server-managed/config.ts +4 -0
- package/server-managed/db-methods.ts +98 -11
- package/server-managed/db-neon.ts +39 -4
- package/server-managed/db-schema.ts +14 -2
- package/server-managed/db-table-names.ts +1 -1
- package/server-managed/db.ts +7 -3
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +9 -3
- package/server-managed/github-errors.ts +21 -2
- package/server-managed/github-issue-oauth.ts +5 -6
- package/server-managed/github-metadata.ts +115 -0
- package/server-managed/github-oauth.ts +38 -2
- package/server-managed/http-response.ts +26 -0
- package/server-managed/http.ts +952 -53
- package/server-managed/index.ts +11 -5
- package/server-managed/kept.ts +28 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +78 -7
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/report-sources.ts +13 -2
- package/server-managed/repository-browser.ts +19 -5
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/session.ts +16 -2
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage-encryption.ts +5 -3
- package/server-managed/storage-payload.ts +4 -0
- package/server-managed/storage-stores.ts +2 -0
- package/server-managed/storage.ts +6 -3
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-feed.ts +1 -1
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +25 -4
- package/server-managed/upstream-cache.ts +147 -5
- package/server-managed/view-as.ts +58 -0
|
@@ -7,7 +7,7 @@ import type { WorkspaceShareFeedState } from './workspace-shares.ts'
|
|
|
7
7
|
|
|
8
8
|
// Short database reads work across instances without pinning a Neon connection.
|
|
9
9
|
// All polling belongs to the awaited request; nothing runs after it completes.
|
|
10
|
-
|
|
10
|
+
const TEAM_FEED_POLL_MS = 3_000
|
|
11
11
|
export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
|
|
12
12
|
const HEARTBEAT_MS = 15_000
|
|
13
13
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// npm scopes a team lists: its members, whatever their role, may read that
|
|
2
|
+
// scope's private packages in the npm viewer (npm-packages.ts), as admins and
|
|
3
|
+
// managers may read any. Hidden teams grant nothing, as for their content.
|
|
4
|
+
import { MAX_TEAM_NPM_SCOPES, normalizeNpmScope } from '../common/managed/npm-packages.js'
|
|
5
|
+
import { ManagedMutationError } from './management.ts'
|
|
6
|
+
import type { ManagedSql } from './sql.ts'
|
|
7
|
+
|
|
8
|
+
export const TEAM_NPM_SCOPE_SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS managed_team_npm_scope (
|
|
10
|
+
team_id TEXT NOT NULL REFERENCES managed_team(id) ON DELETE CASCADE,
|
|
11
|
+
scope TEXT NOT NULL,
|
|
12
|
+
PRIMARY KEY (team_id, scope)
|
|
13
|
+
) STRICT;
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
export interface TeamNpmScopeStore {
|
|
17
|
+
// Every team's scopes, sorted, keyed by team id; teams without any are absent.
|
|
18
|
+
listTeamNpmScopes(): Promise<Record<string, string[]>>
|
|
19
|
+
// Replaces a team's scopes; admin only. Answers what changed, or null for no team.
|
|
20
|
+
setTeamNpmScopes(sessionId: string, teamId: string, scopes: unknown): Promise<{ added: string[]; removed: string[] } | null>
|
|
21
|
+
// The scopes a user's visible teams list.
|
|
22
|
+
listUserNpmScopes(userId: string): Promise<string[]>
|
|
23
|
+
// Whether any visible team lists the scope: whoever reads its packages,
|
|
24
|
+
// they are the instance's own, whose new releases aren't flagged (http.ts).
|
|
25
|
+
hasTeamNpmScope(scope: string): Promise<boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Each distinct scope, normalized; a list with one npm could not publish
|
|
29
|
+
// under, or too many, is refused whole.
|
|
30
|
+
export function parseTeamNpmScopes(input: unknown): string[] {
|
|
31
|
+
if (!Array.isArray(input)) throw new ManagedMutationError(400, 'bad-scopes')
|
|
32
|
+
const scopes = new Set<string>()
|
|
33
|
+
for (const value of input) {
|
|
34
|
+
const scope = normalizeNpmScope(value)
|
|
35
|
+
if (scope === null) throw new ManagedMutationError(400, 'bad-scope')
|
|
36
|
+
scopes.add(scope)
|
|
37
|
+
}
|
|
38
|
+
if (scopes.size > MAX_TEAM_NPM_SCOPES) throw new ManagedMutationError(400, 'too-many-scopes')
|
|
39
|
+
return [...scopes].toSorted()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function teamNpmScopeMethods(db: ManagedSql): TeamNpmScopeStore {
|
|
43
|
+
return {
|
|
44
|
+
async listTeamNpmScopes() {
|
|
45
|
+
const rows = await db.prepare('SELECT team_id AS teamId, scope FROM managed_team_npm_scope ORDER BY team_id, scope').all() as { teamId: string; scope: string }[]
|
|
46
|
+
const result: Record<string, string[]> = {}
|
|
47
|
+
for (const { teamId, scope } of rows) (result[teamId] ??= []).push(scope)
|
|
48
|
+
return result
|
|
49
|
+
},
|
|
50
|
+
async setTeamNpmScopes(sessionId, teamId, input) {
|
|
51
|
+
const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
|
|
52
|
+
WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
|
|
53
|
+
if (!user) throw new ManagedMutationError(401, 'unauthenticated')
|
|
54
|
+
if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
|
|
55
|
+
const scopes = parseTeamNpmScopes(input)
|
|
56
|
+
if (!await db.prepare('SELECT id FROM managed_team WHERE id = ?').get(teamId)) return null
|
|
57
|
+
const current = (await db.prepare('SELECT scope FROM managed_team_npm_scope WHERE team_id = ?').all(teamId) as { scope: string }[]).map(row => row.scope)
|
|
58
|
+
const removed = current.filter(scope => !scopes.includes(scope)).toSorted()
|
|
59
|
+
const added = scopes.filter(scope => !current.includes(scope))
|
|
60
|
+
for (const scope of removed) await db.prepare('DELETE FROM managed_team_npm_scope WHERE team_id = ? AND scope = ?').run(teamId, scope)
|
|
61
|
+
for (const scope of added) await db.prepare('INSERT INTO managed_team_npm_scope (team_id, scope) VALUES (?, ?)').run(teamId, scope)
|
|
62
|
+
return { added, removed }
|
|
63
|
+
},
|
|
64
|
+
async listUserNpmScopes(userId) {
|
|
65
|
+
const rows = await db.prepare(`SELECT DISTINCT ns.scope FROM managed_team_user tu
|
|
66
|
+
JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
|
|
67
|
+
JOIN managed_team_npm_scope ns ON ns.team_id = tu.team_id
|
|
68
|
+
WHERE tu.user_id = ? ORDER BY ns.scope`).all(userId) as { scope: string }[]
|
|
69
|
+
return rows.map(row => row.scope)
|
|
70
|
+
},
|
|
71
|
+
async hasTeamNpmScope(scope) {
|
|
72
|
+
return !!await db.prepare(`SELECT 1 AS listed FROM managed_team_npm_scope ns
|
|
73
|
+
JOIN managed_team t ON t.id = ns.team_id AND t.hidden = 0
|
|
74
|
+
WHERE ns.scope = ? LIMIT 1`).get(scope)
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which of a comparison's pairs of files changed in types alone (type-only.ts),
|
|
2
|
+
// as @preventive/compare's compareTrees stamps a change `typeOnly`, off the
|
|
3
|
+
// server's event loop: each pair a tree of a file a side, a side not there
|
|
4
|
+
// none, its paths as they are, which say what a file is. Two paths pair as one
|
|
5
|
+
// change wherever compareTrees takes them for a rename at all; where it
|
|
6
|
+
// doesn't, the pair is types only where each of its two changes is, as each
|
|
7
|
+
// side holds types alone and neither keeps an `export {}`, which is what it
|
|
8
|
+
// would stamp of them paired. `[true, false, ...]` by pair.
|
|
9
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
10
|
+
import { compareTrees } from '@preventive/compare'
|
|
11
|
+
|
|
12
|
+
const tree = file => (file ? [{ name: file.path, data: file.text }] : [])
|
|
13
|
+
const answer = workerData.pairs.map(([from, to]) => {
|
|
14
|
+
const changes = compareTrees(tree(from), tree(to), { paths: 'keys', lines: false, renames: Number.MIN_VALUE })
|
|
15
|
+
return changes.length > 0 && changes.every(change => change.typeOnly === true)
|
|
16
|
+
})
|
|
17
|
+
// Node worker messages have no browser target origin.
|
|
18
|
+
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
|
19
|
+
parentPort.postMessage(answer)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Compare's changes to TypeScript declaration files in types alone (GET
|
|
2
|
+
// /api/bundles/:id/type-only?other=, GET /api/npm/type-only): every file
|
|
3
|
+
// that changed between a comparison's two sides, added and removed ones too,
|
|
4
|
+
// paired as its viewer pairs them (rename-diff.ts comparedFiles), whose
|
|
5
|
+
// change @preventive/compare's compareTrees stamps
|
|
6
|
+
// `typeOnly` -- each side there a file named as a declaration file and
|
|
7
|
+
// proven to hold types alone, so that it runs nothing (proveTypeOnly) --
|
|
8
|
+
// and nothing else. Proven in a worker, off the event loop, a comparison at a
|
|
9
|
+
// time per process, for at most a minute; told a line each, `{ path, hash,
|
|
10
|
+
// otherPath, otherHash }`, a side not there null, which the viewer checks
|
|
11
|
+
// name the files it shows. Access is the caller's to check.
|
|
12
|
+
import { Worker } from 'node:worker_threads'
|
|
13
|
+
import { type ComparedSide, PrettyError, type PrettyRequest } from './pretty-print.ts'
|
|
14
|
+
import { comparedFiles } from './rename-diff.ts'
|
|
15
|
+
|
|
16
|
+
// TypeScript files, by extension as the pretty-print cache reads them: none
|
|
17
|
+
// other is asked about.
|
|
18
|
+
const TYPESCRIPT = new Set(['ts', 'mts', 'cts'])
|
|
19
|
+
// Comparisons proven at once per process, and those waiting their turn past
|
|
20
|
+
// them, any more refused; the longest one may take, its wait included.
|
|
21
|
+
const MAX_ACTIVE_PROOFS = 1
|
|
22
|
+
const MAX_WAITING_PROOFS = 16
|
|
23
|
+
const PROOF_TIMEOUT_MS = 60_000
|
|
24
|
+
|
|
25
|
+
type File = PrettyRequest & { text: string }
|
|
26
|
+
export interface TypeOnlyLine { path: string | null; hash: string | null; otherPath: string | null; otherHash: string | null }
|
|
27
|
+
|
|
28
|
+
let active = 0
|
|
29
|
+
const waiting: Array<() => void> = []
|
|
30
|
+
|
|
31
|
+
async function turn(deadline: number): Promise<void> {
|
|
32
|
+
if (active < MAX_ACTIVE_PROOFS) { active++; return }
|
|
33
|
+
if (waiting.length >= MAX_WAITING_PROOFS) throw new PrettyError(429, 'proof-busy')
|
|
34
|
+
await new Promise<void>((resolve, reject) => {
|
|
35
|
+
const next = () => { clearTimeout(timer); resolve() }
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
waiting.splice(waiting.indexOf(next), 1)
|
|
38
|
+
reject(new PrettyError(504, 'proof-timeout'))
|
|
39
|
+
}, deadline - Date.now())
|
|
40
|
+
waiting.push(next)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
function done() {
|
|
44
|
+
const next = waiting.shift()
|
|
45
|
+
if (next) next()
|
|
46
|
+
else active--
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Which of `pairs` compareTrees stamps types only, by index, in a worker
|
|
50
|
+
// settled once it is gone; none where it fails, as no proof should.
|
|
51
|
+
function proven(pairs: Array<[File | null, File | null]>, deadline: number): Promise<boolean[]> {
|
|
52
|
+
let worker: Worker
|
|
53
|
+
try {
|
|
54
|
+
worker = new Worker(new URL('./type-only-worker.js', import.meta.url), {
|
|
55
|
+
workerData: { pairs: pairs.map(sides => sides.map(file => file && { path: file.path, text: file.text })) },
|
|
56
|
+
// Node's type stripping, which the proof holds oxc's tree to, warns it is experimental.
|
|
57
|
+
execArgv: ['--disable-warning=ExperimentalWarning'], resourceLimits: { maxOldGenerationSizeMb: 1024 },
|
|
58
|
+
})
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn('managed: a type-only proof could not start:', err)
|
|
61
|
+
throw new PrettyError(503, 'unavailable')
|
|
62
|
+
}
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let answer: boolean[] | null = null, failure = new PrettyError(422, 'unproven')
|
|
65
|
+
const timer = setTimeout(() => { failure = new PrettyError(504, 'proof-timeout'); void worker.terminate() }, deadline - Date.now())
|
|
66
|
+
worker.once('message', (told: unknown) => {
|
|
67
|
+
if (Array.isArray(told) && told.length === pairs.length && told.every(one => typeof one === 'boolean')) answer = told
|
|
68
|
+
void worker.terminate()
|
|
69
|
+
})
|
|
70
|
+
worker.once('error', err => { console.warn('managed: a type-only proof failed:', err) })
|
|
71
|
+
worker.once('exit', () => {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
if (answer) resolve(answer)
|
|
74
|
+
else reject(failure)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The files that changed between a comparison's two sides in types alone,
|
|
80
|
+
// as the viewer pairs them, a line each once all are proven, in path order.
|
|
81
|
+
export function typeOnlyFiles([base, other]: readonly [ComparedSide, ComparedSide]): AsyncGenerator<TypeOnlyLine> {
|
|
82
|
+
const typescript = ({ files }: ComparedSide) => new Map(files.filter(file => TYPESCRIPT.has(file.extension)).map(file => [file.path, file]))
|
|
83
|
+
const [olds, news] = [typescript(base), typescript(other)]
|
|
84
|
+
const { changed, onlyBase, onlyOther } = comparedFiles([base, other])
|
|
85
|
+
// A side there not a TypeScript file this reads leaves its pair out; one not there is null.
|
|
86
|
+
const pairs = [...changed.map(({ path, basePath = path }) => [olds.get(basePath), news.get(path)]),
|
|
87
|
+
...onlyBase.map(({ path, basePath = path }) => [olds.get(basePath), null]), ...onlyOther.map(({ path }) => [null, news.get(path)])]
|
|
88
|
+
.filter((sides): sides is [File | null, File | null] => sides.every(file => file !== undefined) && sides.some(file => file !== null) && sides[0]?.hash !== sides[1]?.hash)
|
|
89
|
+
.toSorted(([a, b], [c, d]) => {
|
|
90
|
+
const [x, y] = [(b ?? a)!.path, (d ?? c)!.path]
|
|
91
|
+
return x < y ? -1 : x > y ? 1 : 0
|
|
92
|
+
})
|
|
93
|
+
return (async function* () {
|
|
94
|
+
if (pairs.length === 0) return
|
|
95
|
+
const deadline = Date.now() + PROOF_TIMEOUT_MS
|
|
96
|
+
await turn(deadline)
|
|
97
|
+
let stamped
|
|
98
|
+
try { stamped = await proven(pairs, deadline) } finally { done() }
|
|
99
|
+
for (const [i, [from, to]] of pairs.entries()) {
|
|
100
|
+
if (stamped[i]) yield { path: from?.path ?? null, hash: from?.hash ?? null, otherPath: to?.path ?? null, otherHash: to?.hash ?? null }
|
|
101
|
+
}
|
|
102
|
+
})()
|
|
103
|
+
}
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// authenticated session, destination and random upload ID. No bearer URL is
|
|
3
3
|
// exposed; all reads/writes still pass the managed role/origin/CSRF gates.
|
|
4
4
|
import { Buffer } from 'node:buffer'
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
5
|
+
import { createHash, generateKeyPairSync } from 'node:crypto'
|
|
6
6
|
import type { IncomingMessage } from 'node:http'
|
|
7
7
|
import type { BlobStore } from './blob-store.ts'
|
|
8
|
+
import { maxSealedBytes, openUpload } from '../common/managed/upload-seal.ts'
|
|
8
9
|
|
|
9
10
|
export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
|
|
10
11
|
export const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
|
|
11
|
-
|
|
12
|
+
// Staged parts may carry a sealed body, slightly larger than its content.
|
|
13
|
+
const MAX_STAGED_BYTES = maxSealedBytes(MAX_UPLOAD_BYTES)
|
|
14
|
+
const MAX_UPLOAD_PARTS = Math.ceil(MAX_STAGED_BYTES / UPLOAD_CHUNK_BYTES)
|
|
12
15
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
|
|
13
16
|
export type UploadKind = 'reports' | 'bundles'
|
|
14
17
|
|
|
@@ -18,7 +21,7 @@ function partId(session: string, kind: UploadKind, id: string, index: number): s
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
|
|
21
|
-
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxBytes,
|
|
24
|
+
return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxSealedBytes(maxBytes), MAX_STAGED_BYTES) / UPLOAD_CHUNK_BYTES)
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export function validUpload(id: string, count: number): boolean {
|
|
@@ -48,7 +51,7 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
|
|
|
48
51
|
if (typeof id !== 'string' || !validUpload(id, count)) throw new Error('bad-upload')
|
|
49
52
|
try {
|
|
50
53
|
if (!Number.isSafeInteger(size) || size <= 0 || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
|
|
51
|
-
if (size > maxBytes || size >
|
|
54
|
+
if (size > maxBytes || size > MAX_STAGED_BYTES) throw new Error('too-large')
|
|
52
55
|
const parts: Buffer[] = []
|
|
53
56
|
for (let index = 0; index < count; index++) {
|
|
54
57
|
const bytes = await store.get(partId(session, kind, id, index))
|
|
@@ -63,3 +66,21 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
|
|
|
63
66
|
await deleteUpload(store, session, kind, id, count).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
64
67
|
}
|
|
65
68
|
}
|
|
69
|
+
|
|
70
|
+
// A session's upload key pair, stored as its private JWK.
|
|
71
|
+
export function newUploadKey(): string {
|
|
72
|
+
const { privateKey } = generateKeyPairSync('x25519')
|
|
73
|
+
const { kty, crv, x, d } = privateKey.export({ format: 'jwk' })
|
|
74
|
+
return JSON.stringify({ kty, crv, x, d })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The raw public key (base64url) browsers seal this session's uploads to.
|
|
78
|
+
export function uploadPublicKey(key: string): string {
|
|
79
|
+
return (JSON.parse(key) as { x: string }).x
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function openSessionUpload(key: string | null | undefined, sealed: Buffer, maxBytes: number): Promise<Buffer> {
|
|
83
|
+
if (!key) throw new Error('bad-upload')
|
|
84
|
+
const bytes = await openUpload(new Uint8Array(sealed.buffer as ArrayBuffer, sealed.byteOffset, sealed.byteLength), JSON.parse(key) as JsonWebKey, maxBytes)
|
|
85
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
86
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { gunzip, gzip } from 'node:zlib'
|
|
7
|
+
import type { CacheStore, Package } from '@preventive/upstream/advisories.js'
|
|
2
8
|
import type { ManagedSql } from './sql.ts'
|
|
3
9
|
|
|
4
10
|
// Records @preventive/upstream would otherwise keep in its disk cache, shared
|
|
@@ -18,30 +24,55 @@ CREATE TABLE IF NOT EXISTS managed_upstream_cache (
|
|
|
18
24
|
|
|
19
25
|
export interface UpstreamCacheStore {
|
|
20
26
|
getUpstreamCacheEntry(key: string): Promise<string | null>
|
|
27
|
+
// Key → value for those of `keys` that are kept.
|
|
28
|
+
getUpstreamCacheEntries(keys: string[]): Promise<Map<string, string>>
|
|
21
29
|
setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
|
|
30
|
+
// Removes those under `prefix` (no LIKE wildcard in it) kept before `before`;
|
|
31
|
+
// how many.
|
|
32
|
+
deleteUpstreamCacheEntries(prefix: string, before: number): Promise<number>
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
|
|
25
36
|
const get = db.prepare('SELECT value FROM managed_upstream_cache WHERE cache_key = ?')
|
|
37
|
+
const getMany = db.prepare('SELECT cache_key, value FROM managed_upstream_cache WHERE cache_key IN (SELECT value FROM json_each(?))')
|
|
26
38
|
// A slower concurrent audit cannot replace a newer listing with an older one.
|
|
27
39
|
const set = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES (?, ?, ?)
|
|
28
40
|
ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
|
|
29
41
|
WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
|
|
42
|
+
const remove = db.prepare('DELETE FROM managed_upstream_cache WHERE cache_key LIKE ? AND cached_at < ?')
|
|
30
43
|
return {
|
|
31
44
|
async getUpstreamCacheEntry(key) {
|
|
32
45
|
const row = await get.get(key) as { value: string } | undefined
|
|
33
46
|
return row?.value ?? null
|
|
34
47
|
},
|
|
48
|
+
async getUpstreamCacheEntries(keys) {
|
|
49
|
+
if (keys.length === 0) return new Map()
|
|
50
|
+
const rows = await getMany.all(JSON.stringify(keys)) as { cache_key: string; value: string }[]
|
|
51
|
+
return new Map(rows.map(row => [row.cache_key, row.value]))
|
|
52
|
+
},
|
|
35
53
|
async setUpstreamCacheEntry(key, value, cachedAt) {
|
|
36
54
|
await set.run(key, value, cachedAt)
|
|
37
55
|
},
|
|
56
|
+
async deleteUpstreamCacheEntries(prefix, before) {
|
|
57
|
+
return Number((await remove.run(`${prefix}%`, before)).changes)
|
|
58
|
+
},
|
|
38
59
|
}
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
// An audit's `cache`: where the server has an upstream disk cache (off Vercel,
|
|
42
63
|
// set at startup), that keeps everything; else this store keeps listings.
|
|
43
|
-
export function auditCache(cacheDir: string | null | undefined, db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore | undefined {
|
|
44
|
-
return cacheDir ? undefined : upstreamCache(db, signal, debug)
|
|
64
|
+
export function auditCache(cacheDir: string | null | undefined, db: UpstreamCacheStore, signal: AbortSignal, debug = false, repos: Iterable<string> = []): CacheStore | undefined {
|
|
65
|
+
return cacheDir ? undefined : upstreamCache(db, signal, debug, repos)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The repositories whose listings an audit of `packages` asks for, where they
|
|
69
|
+
// are already known: each `github` package, each `soldeer` package's given
|
|
70
|
+
// repository, and with `repoAdvisories`, every other package's. Upstream looks
|
|
71
|
+
// up the rest itself.
|
|
72
|
+
export function auditedRepos(packages: Iterable<Package>, repoAdvisories: boolean): string[] {
|
|
73
|
+
const repos = [...packages].map(pkg => (pkg.ecosystem === 'github' ? pkg.name
|
|
74
|
+
: pkg.ecosystem === 'soldeer' || repoAdvisories ? pkg.github : undefined))
|
|
75
|
+
return [...new Set(repos.filter(repo => repo !== undefined))]
|
|
45
76
|
}
|
|
46
77
|
|
|
47
78
|
// Only listings are kept: upstream's other records (npm version documents,
|
|
@@ -51,12 +82,25 @@ const CACHED_TYPE = 'github/advisories'
|
|
|
51
82
|
// The cache is an optimization: a database failure is a miss, never a failed
|
|
52
83
|
// audit. Upstream keeps listing after an abandoned audit; once `signal` aborts,
|
|
53
84
|
// the request's database work is over and the store is left alone.
|
|
54
|
-
|
|
85
|
+
// Upstream reads one listing at a time per repository, and a request's queries
|
|
86
|
+
// share one connection (Neon), so a recheck of hundreds of packages would wait
|
|
87
|
+
// on as many round trips: the first read asks for every listing of `repos`
|
|
88
|
+
// at once. Any other key, or all of them if that read fails, is read alone.
|
|
89
|
+
export function upstreamCache(db: UpstreamCacheStore, signal: AbortSignal, debug = false, repos: Iterable<string> = []): CacheStore {
|
|
90
|
+
// Upstream asks for a listing by its repository's lowercased name.
|
|
91
|
+
const batched = new Set([...repos].map(repo => `${CACHED_TYPE}/${repo.toLowerCase()}`))
|
|
92
|
+
let batch: Promise<Map<string, string> | null> | undefined
|
|
93
|
+
const readBatch = () => batch ??= db.getUpstreamCacheEntries([...batched]).catch((error: unknown) => {
|
|
94
|
+
if (debug) console.warn('managed: upstream cache read failed:', error)
|
|
95
|
+
return null
|
|
96
|
+
})
|
|
55
97
|
return {
|
|
56
98
|
async read(type, key) {
|
|
57
99
|
if (type !== CACHED_TYPE || signal.aborted) return null
|
|
100
|
+
const cacheKey = `${type}/${key}`
|
|
58
101
|
try {
|
|
59
|
-
const
|
|
102
|
+
const found = batched.has(cacheKey) ? await readBatch() : null
|
|
103
|
+
const value = found ? found.get(cacheKey) ?? null : await db.getUpstreamCacheEntry(cacheKey)
|
|
60
104
|
return value === null ? null : JSON.parse(value) as unknown
|
|
61
105
|
} catch (error) {
|
|
62
106
|
if (debug) console.warn('managed: upstream cache read failed:', error)
|
|
@@ -71,3 +115,101 @@ export function upstreamCache(db: UpstreamCacheStore, signal: AbortSignal, debug
|
|
|
71
115
|
},
|
|
72
116
|
}
|
|
73
117
|
}
|
|
118
|
+
|
|
119
|
+
// The file upstream keeps `file` in, under `dir` of its disk cache
|
|
120
|
+
// (setCacheDir), as its cachePath names it: `@babel+core@7.29.7.tgz`, a
|
|
121
|
+
// capital written `!` and the letter, and a Windows device name's first
|
|
122
|
+
// letter escaped.
|
|
123
|
+
export function upstreamCachePath(root: string, dir: string, file: string): string {
|
|
124
|
+
const name = encodeURIComponent(file.replaceAll(/[!A-Z]/gu, char => `!${char.toLowerCase()}`))
|
|
125
|
+
.replaceAll('%40', '@').replaceAll('%2F', '+')
|
|
126
|
+
.replace(/^(?=(?:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))./u, char => `%${char.codePointAt(0)!.toString(16).toUpperCase()}`)
|
|
127
|
+
return join(root, dir, name)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A kept file, where it is a regular file of at most `max` bytes, checked on
|
|
131
|
+
// the file as opened (a FIFO or device would block or never end), and read
|
|
132
|
+
// to the size it had then.
|
|
133
|
+
export async function readKeptFile(path: string, max: number): Promise<Uint8Array | null> {
|
|
134
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK).catch(() => null)
|
|
135
|
+
if (!handle) return null
|
|
136
|
+
try {
|
|
137
|
+
const stats = await handle.stat()
|
|
138
|
+
if (!stats.isFile() || stats.size > max) return null
|
|
139
|
+
const bytes = Buffer.allocUnsafe(stats.size)
|
|
140
|
+
for (let at = 0; at < bytes.length;) {
|
|
141
|
+
const { bytesRead } = await handle.read(bytes, at, bytes.length - at, at)
|
|
142
|
+
if (bytesRead === 0) return null
|
|
143
|
+
at += bytesRead
|
|
144
|
+
}
|
|
145
|
+
return bytes
|
|
146
|
+
} catch { return null }
|
|
147
|
+
finally { await handle.close() }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `bytes` kept at `path`, written whole beside it first and then moved in, so a
|
|
151
|
+
// reader finds the old file or the new one, never part of one; a failure
|
|
152
|
+
// keeps nothing.
|
|
153
|
+
let tmpSeq = 0
|
|
154
|
+
export async function keepFile(path: string, bytes: Uint8Array) {
|
|
155
|
+
const tmp = `${path}.${process.pid}.${++tmpSeq}.tmp`
|
|
156
|
+
try {
|
|
157
|
+
await mkdir(dirname(path), { recursive: true })
|
|
158
|
+
await writeFile(tmp, bytes)
|
|
159
|
+
await rename(tmp, path)
|
|
160
|
+
} catch { await rm(tmp, { force: true }).catch(() => {}) }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const gunzipAsync = promisify(gunzip)
|
|
164
|
+
const gzipAsync = promisify(gzip)
|
|
165
|
+
|
|
166
|
+
// An upstream's JSON object kept with its ETag, gzipped, as keepJson keeps it:
|
|
167
|
+
// `{ etag, json }`, or null where none is kept, or what is can't be read
|
|
168
|
+
// within `limit` bytes.
|
|
169
|
+
export async function readKeptJson(path: string, limit: number): Promise<{ etag: string; json: Record<string, unknown> } | null> {
|
|
170
|
+
const bytes = await readKeptFile(path, limit)
|
|
171
|
+
if (!bytes) return null
|
|
172
|
+
const object = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
173
|
+
try {
|
|
174
|
+
const kept: unknown = JSON.parse((await gunzipAsync(bytes, { maxOutputLength: limit + 1024 })).toString('utf8'))
|
|
175
|
+
return object(kept) && typeof kept['etag'] === 'string' && object(kept['json']) ? { etag: kept['etag'], json: kept['json'] } : null
|
|
176
|
+
} catch { return null }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// `body`, an upstream's JSON object as it wrote it, kept with its `etag`.
|
|
180
|
+
export async function keepJson(path: string, etag: string, body: Buffer) {
|
|
181
|
+
await keepFile(path, await gzipAsync(Buffer.concat([Buffer.from(`{"etag":${JSON.stringify(etag)},"json":`), body, Buffer.from('}')])))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A kept listing is read to no more than this, gzipped or not: one runs to
|
|
185
|
+
// some hundreds of KiB.
|
|
186
|
+
const LISTING_BYTES = 64 * 1024 * 1024
|
|
187
|
+
|
|
188
|
+
// A repository's listing as last kept where audits keep them (auditCache),
|
|
189
|
+
// however old, for when GitHub can't be reached to refresh it: `at`, when it
|
|
190
|
+
// was kept, and `cache`, a store answering it to upstream as kept just now,
|
|
191
|
+
// so that upstream takes it rather than ask GitHub, and keeping nothing.
|
|
192
|
+
// Upstream checks it as it checks its own. Null where none is kept, or it
|
|
193
|
+
// can't be read.
|
|
194
|
+
export async function lastListing(cacheDir: string | null | undefined, db: UpstreamCacheStore, repo: string, debug = false): Promise<{ at: number; cache: CacheStore } | null> {
|
|
195
|
+
const key = repo.toLowerCase()
|
|
196
|
+
let kept: { at?: unknown } | null
|
|
197
|
+
try {
|
|
198
|
+
if (cacheDir) {
|
|
199
|
+
const bytes = await readKeptFile(upstreamCachePath(cacheDir, CACHED_TYPE, `${key}.json.gz`), LISTING_BYTES)
|
|
200
|
+
kept = bytes && JSON.parse((await gunzipAsync(bytes, { maxOutputLength: LISTING_BYTES })).toString('utf8'))
|
|
201
|
+
} else {
|
|
202
|
+
const value = await db.getUpstreamCacheEntry(`${CACHED_TYPE}/${key}`)
|
|
203
|
+
kept = value === null ? null : JSON.parse(value)
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (debug) console.warn('managed: kept listing read failed:', error)
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
const at = kept?.at
|
|
210
|
+
if (typeof at !== 'number') return null
|
|
211
|
+
return { at, cache: {
|
|
212
|
+
read: (type, name) => Promise.resolve(type === CACHED_TYPE && name === key ? { ...kept, at: Date.now() } : null),
|
|
213
|
+
write: () => Promise.resolve(),
|
|
214
|
+
} }
|
|
215
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// An administrator's read-only view as another user, for checking what that
|
|
2
|
+
// account can access. The view is a session row for the viewed user (so every
|
|
3
|
+
// access check applies that user's role, teams and permissions unchanged) tied
|
|
4
|
+
// to the admin's own session, carried in its own cookie beside the session
|
|
5
|
+
// cookie. Ending the admin session, or the admin role, ends the view.
|
|
6
|
+
//
|
|
7
|
+
// The router refuses writes for any request that carries the view cookie, and
|
|
8
|
+
// such requests never act with the viewed user's GitHub credentials: GitHub
|
|
9
|
+
// data that needs them is left out instead.
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
11
|
+
import type { ManagedConfig } from './config.ts'
|
|
12
|
+
import type { ManagedDb, ManagedSession } from './db.ts'
|
|
13
|
+
import { hashToken, randomToken } from './crypto.ts'
|
|
14
|
+
import { VIEW_COOKIE, buildCookie, clearCookie, cookieName, parseCookies } from './session.ts'
|
|
15
|
+
|
|
16
|
+
export const VIEW_AS_PATH = '/api/auth/view-as'
|
|
17
|
+
export const VIEW_ONLY_ERROR = 'view-only'
|
|
18
|
+
|
|
19
|
+
function viewCookieName(config: ManagedConfig): string {
|
|
20
|
+
return cookieName(config, VIEW_COOKIE)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The raw view token, or null when the request carries no view.
|
|
24
|
+
export function viewToken(config: ManagedConfig, cookieHeader: string | undefined): string | null {
|
|
25
|
+
const token = parseCookies(cookieHeader).get(viewCookieName(config))
|
|
26
|
+
return token == null || token === '' ? null : token
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearViewCookie(config: ManagedConfig): string {
|
|
30
|
+
return clearCookie(viewCookieName(config), config.cookieSecure)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Open a view as `userId` from the admin's `session`, replacing any earlier
|
|
34
|
+
// view of that session. Null when the database refuses it (see createViewSession).
|
|
35
|
+
export async function startViewSession(config: ManagedConfig, db: ManagedDb, session: ManagedSession, userId: string, now: number): Promise<string | null> {
|
|
36
|
+
const token = randomToken()
|
|
37
|
+
const ok = await db.createViewSession({ id: hashToken(token), viewerSessionId: session.id, userId, csrfToken: randomToken() }, now)
|
|
38
|
+
if (!ok) return null
|
|
39
|
+
return buildCookie(viewCookieName(config), token, {
|
|
40
|
+
maxAgeS: Math.max(0, Math.floor((session.expiresAt - now) / 1000)), secure: config.cookieSecure, sameSite: 'Lax',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function endViewSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
|
|
45
|
+
const token = viewToken(config, cookieHeader)
|
|
46
|
+
if (token != null) await db.deleteSession(hashToken(token))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const viewing = new AsyncLocalStorage<boolean>()
|
|
50
|
+
|
|
51
|
+
export function runViewing<T>(active: boolean, work: () => T): T {
|
|
52
|
+
return active ? viewing.run(true, work) : work()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Whether the current request is an admin's view as another user.
|
|
56
|
+
export function isViewing(): boolean {
|
|
57
|
+
return viewing.getStore() === true
|
|
58
|
+
}
|