@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.44
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 +2 -167
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/managed/report-filter.ts +2 -1
- package/common/managed/routes.js +3 -3
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +8 -0
- package/common/workspace-app.js +14 -1
- package/out/chunk-2ABLKCOY.js +46 -0
- package/out/chunk-5LSXOLUS.js +1 -0
- package/out/chunk-5RCM2J63.js +1 -0
- package/out/chunk-7B6HB4D7.js +1 -0
- package/out/{chunk-4F6JSF57.js → chunk-AJ4GTATZ.js} +1 -1
- package/out/chunk-CH4UN6WK.js +225 -0
- package/out/chunk-EDKLBQQN.js +17 -0
- package/out/{chunk-UXNUAIIC.js → chunk-F47DXCEV.js} +1 -1
- package/out/chunk-FK3DZEKL.js +1 -0
- package/out/chunk-G6AJWENO.js +1 -0
- package/out/chunk-GZWM7UQM.js +2 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-LPCTJYNA.js +7 -0
- package/out/{chunk-DGVQPIYB.js → chunk-O4RJY5TP.js} +1 -1
- package/out/chunk-OD4LPN3H.js +1 -0
- package/out/{chunk-D7AF2JXL.js → chunk-QWDWTBM7.js} +1 -1
- package/out/chunk-QYWKC557.js +1 -0
- package/out/chunk-R75XOKUP.js +1 -0
- package/out/{chunk-RSHMVYNS.js → chunk-SWTLADW5.js} +1 -1
- package/out/chunk-T7OKH4VW.js +1 -0
- package/out/{chunk-ZMQQCGF6.js → chunk-UTR2LVME.js} +1 -1
- package/out/chunk-ZESLQCTF.js +6 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +21 -21
- package/out/client-sync.js +1 -1
- package/out/graph.js +1 -1
- package/out/index.html +14 -10
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +58 -58
- package/package.json +13 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +66 -15
- package/risk/npm/dependencies.js +22 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/comparison.ts +41 -0
- package/server-managed/http.ts +161 -56
- package/server-managed/locked-packages.ts +29 -6
- package/server-managed/npm-kept.ts +38 -14
- package/server-managed/npm-packages.ts +27 -13
- package/server-managed/npm-risk.ts +69 -11
- package/server-managed/pretty-print.ts +14 -9
- package/server-managed/public-workspace.ts +26 -8
- package/server-managed/queue.ts +23 -0
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +66 -0
- package/server-managed/repository-risk.ts +265 -209
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-app.ts +38 -14
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
- package/out/chunk-CLPKNNC7.js +0 -51
- package/out/chunk-Q7ZSIEHP.js +0 -1
- package/out/chunk-RFZQQXSG.js +0 -17
- package/out/chunk-VQJCXGIY.js +0 -7
- package/out/chunk-WOTMRDM3.js +0 -226
- package/out/chunk-WZDTZE64.js +0 -1
- package/out/chunk-YP2537WX.js +0 -1
- package/out/chunk-YQRVLRDH.js +0 -1
- package/out/chunk-YVZH5SHP.js +0 -1
- package/out/chunk-ZBBOBQ43.js +0 -1
package/server-managed/http.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// server doesn't speak the e2e sync protocol yet). Every handler is async; the
|
|
4
4
|
// boot's `track()` keeps in-flight requests drainable on shutdown.
|
|
5
5
|
//
|
|
6
|
-
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public
|
|
6
|
+
// GET /api/config → { mode:'managed', managed:{ loginPath, cookieName }, session } (public;
|
|
7
|
+
// `session` is /api/auth/session's answer, null signed out, absent when it could not be read)
|
|
7
8
|
// GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
|
|
8
9
|
// GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
|
|
9
10
|
// GET /api/auth/session → { user, csrfToken } | 401
|
|
@@ -89,7 +90,7 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
89
90
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
90
91
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
91
92
|
import { pipeline } from 'node:stream/promises'
|
|
92
|
-
import { npmVersionStore, riskLineStore } from './npm-kept.ts'
|
|
93
|
+
import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
93
94
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
94
95
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
95
96
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -120,7 +121,7 @@ import { findingsRepository, loadManagedFindings, ownFileDirectory, readManagedR
|
|
|
120
121
|
import type { ReportSourcesCache } from './report-sources.ts'
|
|
121
122
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
122
123
|
import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
|
|
123
|
-
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
124
|
+
import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
|
|
124
125
|
import { bundleCommitHash } from '../common/bundle-commit.js'
|
|
125
126
|
import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
|
|
126
127
|
import { upstreamGithub } from './github-errors.ts'
|
|
@@ -128,7 +129,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
128
129
|
import type { ConnectedRepo } from './github-app.ts'
|
|
129
130
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
130
131
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
131
|
-
import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
|
|
132
|
+
import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
|
|
132
133
|
import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
|
|
133
134
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
134
135
|
import { clearCookie, endSession, readSession } from './session.ts'
|
|
@@ -146,23 +147,24 @@ import { attachmentDisposition, sendJson, sendLines, writeResponse } from './htt
|
|
|
146
147
|
import { triageWireEntry } from './triage-response.ts'
|
|
147
148
|
import { handlePublicWorkspace } from './public-workspace.ts'
|
|
148
149
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
149
|
-
import { teamAppKey, teamAppStates } from './team-app.ts'
|
|
150
|
+
import { teamAppKey, teamAppStates, withWorkspaceSummary } from './team-app.ts'
|
|
150
151
|
import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
151
152
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
152
153
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
153
154
|
import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
|
|
154
|
-
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
156
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
156
157
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
157
158
|
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
158
159
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
159
160
|
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
160
161
|
import { npmProvenance } from './npm-provenance.ts'
|
|
161
|
-
import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
162
|
+
import { npmReadyScanDownload, npmReadyScanDownloads, npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
162
163
|
import { NpmRiskError } from '../risk/index.js'
|
|
163
164
|
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
165
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
165
166
|
import { typeOnlyFiles } from './type-only.ts'
|
|
167
|
+
import { comparisonBody, emptySide, npmComparedSide } from './comparison.ts'
|
|
166
168
|
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
167
169
|
|
|
168
170
|
const SESSION_PATH = '/api/auth/session'
|
|
@@ -216,6 +218,7 @@ const NPM_PROVENANCE_PATH = '/api/npm/provenance'
|
|
|
216
218
|
const NPM_PRETTY_PATH = '/api/npm/pretty'
|
|
217
219
|
const NPM_RENAME_DIFF_PATH = '/api/npm/rename-diff'
|
|
218
220
|
const NPM_TYPE_ONLY_PATH = '/api/npm/type-only'
|
|
221
|
+
const NPM_COMPARISON_PATH = '/api/npm/comparison'
|
|
219
222
|
const NPM_RISK_PATH = '/api/npm/risk'
|
|
220
223
|
// What risk's errors (risk/npm/registry.js NpmRiskError) are answered as: npm
|
|
221
224
|
// having no such package or version, or a tarball unpacking past risk's
|
|
@@ -229,7 +232,7 @@ function sendRiskError(res: ServerResponse, err: NpmRiskError) {
|
|
|
229
232
|
sendJson(res, status, { error: err.code })
|
|
230
233
|
}
|
|
231
234
|
const NPM_PATHS = new Set([NPM_PACKAGE_PATH, NPM_VERSIONS_PATH, NPM_DOWNLOAD_PATH, NPM_STATS_PATH, NPM_ADVISORIES_PATH, NPM_TAGS_PATH, NPM_SOCKET_PATH, NPM_PROVENANCE_PATH, NPM_PRETTY_PATH,
|
|
232
|
-
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH])
|
|
235
|
+
NPM_RENAME_DIFF_PATH, NPM_TYPE_ONLY_PATH, NPM_RISK_PATH, NPM_COMPARISON_PATH])
|
|
233
236
|
// How long an npm response may go unread before its connection is dropped.
|
|
234
237
|
const NPM_RESPONSE_IDLE_MS = 60_000
|
|
235
238
|
const MAX_TEAM_NAME = 100
|
|
@@ -581,6 +584,24 @@ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
581
584
|
return s
|
|
582
585
|
}
|
|
583
586
|
|
|
587
|
+
// Who am I? While an admin views as another user, that user, plus the admin
|
|
588
|
+
// as `viewer`. A view that has ended (with the admin's session or role, or the
|
|
589
|
+
// viewed account) is cleared, returning to the own session. Null signed out.
|
|
590
|
+
async function currentSession(config: ManagedConfig, db: ManagedDb, cookie: string | undefined): Promise<{ body: ManagedSessionInfo | null; headers: Record<string, string> }> {
|
|
591
|
+
const now = Date.now()
|
|
592
|
+
let s = await readSession(config, db, cookie, now)
|
|
593
|
+
const ended = s == null && viewToken(config, cookie) != null
|
|
594
|
+
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
595
|
+
return {
|
|
596
|
+
body: s && {
|
|
597
|
+
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
598
|
+
csrfToken: s.session.csrfToken,
|
|
599
|
+
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
600
|
+
},
|
|
601
|
+
headers: ended ? { 'set-cookie': clearViewCookie(config) } : {},
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
584
605
|
// GET /api/admin/scan/models — the server's canonical scan model ids and effort
|
|
585
606
|
// levels. Names are intentionally absent; the client derives them from ids.
|
|
586
607
|
async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
|
|
@@ -1351,6 +1372,10 @@ async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: Open
|
|
|
1351
1372
|
// go idle, unread or with no line to send, before the scan is stopped.
|
|
1352
1373
|
const REPO_RISK_RECHECK_MS = 10_000
|
|
1353
1374
|
const REPO_RISK_IDLE_MS = 5 * 60_000
|
|
1375
|
+
// A scan's part on a serverless deployment, within its function's 300s
|
|
1376
|
+
// (vercel.managed.json): past it the scan stops, its last line `continue`,
|
|
1377
|
+
// and the page asks again, from where it stopped (repository-risk.ts).
|
|
1378
|
+
const REPO_RISK_PART_MS = 270_000
|
|
1354
1379
|
|
|
1355
1380
|
// The repositories a scan of risk reads, by its query: every connected one;
|
|
1356
1381
|
// a team's (`team`, its id), each in the team's directories of it; or one
|
|
@@ -1379,6 +1404,14 @@ async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<
|
|
|
1379
1404
|
})
|
|
1380
1405
|
}
|
|
1381
1406
|
|
|
1407
|
+
// Where a scan in parts keeps its progress (repository-risk.ts
|
|
1408
|
+
// ScanProgress) under `key`, for a day; none where the page gave no scan id.
|
|
1409
|
+
function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
1410
|
+
if (scan === null || !/^[\w-]{1,64}$/u.test(scan)) return undefined
|
|
1411
|
+
const kept = scanReadStore<ScanProgress>(deps.db, 'progress', SCAN_READ_KEPT_MS, deps.config.debug)
|
|
1412
|
+
return { read: () => kept.read(key), write: (progress: ScanProgress) => kept.write(key, progress, Date.now()) }
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1382
1415
|
// GET /api/admin/repositories/risk: every connected repository's npm
|
|
1383
1416
|
// packages, or a team's or one repository's (riskRepositories), as its
|
|
1384
1417
|
// lockfiles lock them at its default branch's head, each assessed by risk
|
|
@@ -1404,8 +1437,19 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1404
1437
|
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1405
1438
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
|
|
1406
1439
|
const scopes = new Map<string, Promise<boolean>>()
|
|
1440
|
+
const teamScoped = (name: string) => {
|
|
1441
|
+
const scope = npmPackageScope(name)
|
|
1442
|
+
if (scope === null) return Promise.resolve(false)
|
|
1443
|
+
if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
|
|
1444
|
+
return scopes.get(scope)!
|
|
1445
|
+
}
|
|
1407
1446
|
const versions = npmVersionStore(deps.db, deps.config.debug)
|
|
1447
|
+
const stores = { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug }
|
|
1408
1448
|
const lines = repositoryRiskLines({
|
|
1449
|
+
// Which repositories a scan in parts told whole, by the admin, the id the
|
|
1450
|
+
// page gives the scan (`scan`) and its scope, for the next part to go on
|
|
1451
|
+
// past them.
|
|
1452
|
+
progress: scanProgress(deps, `${s.user.id}\0${query.get('scan')}\0${query.get('team') ?? ''}\0${query.get('repo') ?? ''}`, query.get('scan')),
|
|
1409
1453
|
repos: () => Promise.resolve(repos),
|
|
1410
1454
|
async open(repo) {
|
|
1411
1455
|
const reader = await browser.reader(repo)
|
|
@@ -1418,21 +1462,32 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1418
1462
|
recheck: async () => { await reader.recheckAccess() },
|
|
1419
1463
|
}
|
|
1420
1464
|
},
|
|
1421
|
-
teamScoped
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1465
|
+
teamScoped,
|
|
1466
|
+
// Each package's downloads readied before it is assessed
|
|
1467
|
+
// (scan-downloads.ts), so that none waits on npm's downloads API; a
|
|
1468
|
+
// team's own not asked, as risk asks none of theirs.
|
|
1469
|
+
downloads: {
|
|
1470
|
+
async atOnce(names, ready, signal) {
|
|
1471
|
+
const asked: string[] = []
|
|
1472
|
+
for (const name of names) if (await teamScoped(name)) ready(name); else asked.push(name)
|
|
1473
|
+
await npmReadyScanDownloads(asked, ready, stores, signal)
|
|
1474
|
+
},
|
|
1475
|
+
one: (name, signal) => npmReadyScanDownload(name, stores, signal),
|
|
1426
1476
|
},
|
|
1427
1477
|
// Public versions' documents kept in the database (npm-kept.ts), as the
|
|
1428
1478
|
// viewer keeps them: whether npm still has one, its whole document tells
|
|
1429
1479
|
// risk.
|
|
1430
1480
|
version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
|
|
1431
|
-
assess: (doc,
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1481
|
+
assess: (doc, ownScope, signal) => npmRisk(doc, 'pick', { ...stores, teamScoped: ownScope, privileged: true, signal, spare: true }),
|
|
1482
|
+
removed: (name, version, ownScope, signal) => npmRiskRemoved(name, version, { ...stores, teamScoped: ownScope, privileged: true, signal }),
|
|
1483
|
+
// Kept for good, a scan telling one past RISK_KEPT_MS at once, as stale.
|
|
1484
|
+
risks: riskLineStore(deps.db, Infinity, deps.config.debug),
|
|
1485
|
+
// What it read of GitHub kept a day, for a scan answered in parts to go
|
|
1486
|
+
// on from where the last stopped, on whichever instance answers the next.
|
|
1487
|
+
listings: scanReadStore(deps.db, 'listings', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
1488
|
+
lockfiles: scanReadStore(deps.db, 'lockfiles', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
1489
|
+
// And each lockfile's advisories, for as long as a scan tells them.
|
|
1490
|
+
audits: scanReadStore(deps.db, 'advisories', RISK_KEPT_MS, deps.config.debug),
|
|
1436
1491
|
// As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
|
|
1437
1492
|
// and, for packages anyone may read, OSV: no repository's. Each lockfile's
|
|
1438
1493
|
// audit is bounded on its own, for one npm doesn't answer to fail alone and
|
|
@@ -1444,7 +1499,7 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1444
1499
|
if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
|
|
1445
1500
|
return audited.body
|
|
1446
1501
|
},
|
|
1447
|
-
}, controller.signal)
|
|
1502
|
+
}, controller.signal, undefined, { partMs: deps.config.serverless ? REPO_RISK_PART_MS : null })
|
|
1448
1503
|
let checked = Date.now()
|
|
1449
1504
|
await sendLines(res, lines, async () => {
|
|
1450
1505
|
if (deps.isShuttingDown()) return false
|
|
@@ -1481,18 +1536,25 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
|
|
|
1481
1536
|
await sendEncoded(req, res, cached, contentType, headers)
|
|
1482
1537
|
}
|
|
1483
1538
|
|
|
1539
|
+
// Two bundles a reader asks about together, where they may read both, else
|
|
1540
|
+
// null, told as 401 or 404: asked before what is worked out of them and
|
|
1541
|
+
// again before it is sent.
|
|
1542
|
+
function bothBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, otherId: string) {
|
|
1543
|
+
return async () => {
|
|
1544
|
+
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1545
|
+
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1546
|
+
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1547
|
+
return [base.bundle, other.bundle] as const
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1484
1551
|
// GET /api/bundles/:id/rename-diff: one of its JavaScript files diffed with
|
|
1485
1552
|
// one of the `other` bundle's, both pretty-printed, renamed names hidden
|
|
1486
1553
|
// (rename-diff.ts), for a reader of both, checked again once it is worked out.
|
|
1487
1554
|
async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1488
1555
|
const otherId = params.get('other') ?? '', request = renameDiffRequest(params)
|
|
1489
1556
|
if (!request || !/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
1490
|
-
const readable =
|
|
1491
|
-
const [base, other] = await Promise.all([id, otherId].map(bundle => deps.db.getBundleAccessSnapshot(session.id, Date.now(), bundle)))
|
|
1492
|
-
if (!base || !other) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
1493
|
-
if (!base.bundle || !other.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
|
|
1494
|
-
return [base.bundle, other.bundle] as const
|
|
1495
|
-
}
|
|
1557
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
1496
1558
|
const records = await readable()
|
|
1497
1559
|
if (!records) return
|
|
1498
1560
|
const { prettyCache } = deps
|
|
@@ -1508,6 +1570,25 @@ async function handleBundleRenameDiff(req: IncomingMessage, res: ServerResponse,
|
|
|
1508
1570
|
else diff.stream.destroy()
|
|
1509
1571
|
}
|
|
1510
1572
|
|
|
1573
|
+
// GET /api/bundles/:id/comparison?other=: its files paired with the `other`
|
|
1574
|
+
// bundle's as Compare pairs them, with what its Overview reads beside them
|
|
1575
|
+
// (comparison.ts), within a reason `scope` where given; for a reader of
|
|
1576
|
+
// both, checked again once it is worked out.
|
|
1577
|
+
async function handleBundleComparison(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, params: URLSearchParams) {
|
|
1578
|
+
const otherId = params.get('other') ?? ''
|
|
1579
|
+
if (!/^[a-f\d-]{36}$/iu.test(otherId)) { sendJson(res, 400, { error: 'bad-bundle' }); return }
|
|
1580
|
+
const readable = bothBundles(res, deps, session, id, otherId)
|
|
1581
|
+
const records = await readable()
|
|
1582
|
+
if (!records) return
|
|
1583
|
+
const { prettyCache } = deps
|
|
1584
|
+
if (!prettyCache) { sendJson(res, 503, { error: 'unavailable' }); return }
|
|
1585
|
+
let body
|
|
1586
|
+
try { body = await comparisonBody(await prettyCache.bundleDetails(records[0]), await prettyCache.bundleDetails(records[1]), params.get('scope') ?? '') }
|
|
1587
|
+
catch (err) { sendJson(res, err instanceof PrettyError ? err.status : 422, { error: err instanceof PrettyError ? err.message : 'bundle-unavailable' }); return }
|
|
1588
|
+
if (await readable()) await sendEncoded(req, res, body, 'application/json')
|
|
1589
|
+
else body.stream.destroy()
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1511
1592
|
// GET /api/bundles/:id/pretty?other=: the files that changed between it and
|
|
1512
1593
|
// the `other` bundle, both sides' pretty-printed (pretty-print.ts compared), a
|
|
1513
1594
|
// line each as it is found kept or formatted; GET
|
|
@@ -1864,7 +1945,7 @@ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session
|
|
|
1864
1945
|
// The revision sent is the reloaded catalog's, so classify what changed now.
|
|
1865
1946
|
const apps = await teamAppStates(deps.db, deps.reportStore, session.id, teams, classified)
|
|
1866
1947
|
sendJson(res, 200, {
|
|
1867
|
-
teams: teams.map(team => ({ ...team,
|
|
1948
|
+
teams: teams.map(team => ({ ...withWorkspaceSummary(team, apps.get(teamAppKey(team))),
|
|
1868
1949
|
bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
|
|
1869
1950
|
commitInfo: commits.commitInfo(bundle) })) })),
|
|
1870
1951
|
revision,
|
|
@@ -2614,12 +2695,15 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
2614
2695
|
sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
|
|
2615
2696
|
}
|
|
2616
2697
|
|
|
2617
|
-
// Who reads npm packages now: a session with workspace access, its role and
|
|
2618
|
-
//
|
|
2619
|
-
|
|
2698
|
+
// Who reads npm packages now: a session with workspace access, its role and,
|
|
2699
|
+
// where they count for any of `names` it is asked about (npmScopesCount), the
|
|
2700
|
+
// npm scopes of its visible teams, else none: none are read for an unscoped
|
|
2701
|
+
// package, an admin or a manager, or an answer checked again that is public.
|
|
2702
|
+
// Null for none.
|
|
2703
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
2620
2704
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2621
2705
|
if (s == null || !roleAtLeast(s.user.role, 'view')) return null
|
|
2622
|
-
return { role: s.user.role, scopes: new Set(await deps.db.listUserNpmScopes(s.user.id)), userId: s.user.id }
|
|
2706
|
+
return { role: s.user.role, scopes: new Set(npmScopesCount(s.user.role, names) ? await deps.db.listUserNpmScopes(s.user.id) : []), userId: s.user.id }
|
|
2623
2707
|
}
|
|
2624
2708
|
|
|
2625
2709
|
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
@@ -2636,7 +2720,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2636
2720
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2637
2721
|
res.on('close', onClose)
|
|
2638
2722
|
try {
|
|
2639
|
-
const reader = await npmReader(deps, cookie)
|
|
2723
|
+
const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
|
|
2640
2724
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2641
2725
|
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2642
2726
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
@@ -2650,7 +2734,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2650
2734
|
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2651
2735
|
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2652
2736
|
])))
|
|
2653
|
-
const current = await npmReader(deps, cookie)
|
|
2737
|
+
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
2654
2738
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2655
2739
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
2656
2740
|
} catch (error) {
|
|
@@ -2760,7 +2844,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2760
2844
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2761
2845
|
res.on('close', onClose)
|
|
2762
2846
|
try {
|
|
2763
|
-
const reader = await npmReader(deps, cookie)
|
|
2847
|
+
const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
|
|
2764
2848
|
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2765
2849
|
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2766
2850
|
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
@@ -2791,7 +2875,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2791
2875
|
}
|
|
2792
2876
|
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
2793
2877
|
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
2794
|
-
const current = await npmReader(deps, cookie)
|
|
2878
|
+
const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
|
|
2795
2879
|
if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2796
2880
|
if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2797
2881
|
if (!change) {
|
|
@@ -2817,7 +2901,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2817
2901
|
} finally { res.off('close', onClose) }
|
|
2818
2902
|
}
|
|
2819
2903
|
|
|
2820
|
-
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only}. Only readers with private access
|
|
2904
|
+
// GET /api/npm/{package,versions,download,stats,advisories,tags,socket,provenance,pretty,rename-diff,type-only,risk,comparison}. Only readers with private access
|
|
2821
2905
|
// (canReadPrivateNpm) can be answered from the server's npm token; the rest get
|
|
2822
2906
|
// what the registry answers anonymously, every time (see npm-packages.ts).
|
|
2823
2907
|
// Access is checked again after the registry's answer.
|
|
@@ -2827,14 +2911,17 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2827
2911
|
// All but the package's own (its versions, figures and advisories) are a version's.
|
|
2828
2912
|
const versioned = path !== NPM_VERSIONS_PATH && path !== NPM_STATS_PATH && path !== NPM_ADVISORIES_PATH
|
|
2829
2913
|
const otherSpec = params.get('otherVersion') ?? ''
|
|
2830
|
-
|
|
2914
|
+
// The package a comparison's other side is a version of, another where Review compares a dependency replaced.
|
|
2915
|
+
const otherName = path === NPM_COMPARISON_PATH ? params.get('otherName') ?? name : name
|
|
2916
|
+
if (!isNpmPackageName(name) || !isNpmPackageName(otherName) || (versioned && !isNpmPackageSpec(spec)) || ((path === NPM_RENAME_DIFF_PATH || path === NPM_TYPE_ONLY_PATH) && !isNpmPackageSpec(otherSpec))
|
|
2831
2917
|
|| (path === NPM_PRETTY_PATH && otherSpec !== '' && !isNpmPackageSpec(otherSpec))
|
|
2832
|
-
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2918
|
+
|| (path === NPM_RISK_PATH && otherSpec !== '' && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))
|
|
2919
|
+
|| (path === NPM_COMPARISON_PATH && otherSpec !== '(empty)' && !isNpmPackageSpec(otherSpec))) {
|
|
2833
2920
|
sendJson(res, 400, { error: 'bad-package' }); return
|
|
2834
2921
|
}
|
|
2835
2922
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2836
2923
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2837
|
-
const reader = await npmReader(deps, cookie)
|
|
2924
|
+
const reader = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
2838
2925
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2839
2926
|
const controller = new AbortController()
|
|
2840
2927
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -2842,7 +2929,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2842
2929
|
// A reader who lost their session, or their private access to an answer
|
|
2843
2930
|
// that needed it, while the registry was asked gets nothing.
|
|
2844
2931
|
const recheck = async (isPrivate: boolean) => {
|
|
2845
|
-
const current = await npmReader(deps, cookie)
|
|
2932
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
2846
2933
|
if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
2847
2934
|
if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
|
|
2848
2935
|
return !res.destroyed
|
|
@@ -2933,6 +3020,28 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2933
3020
|
return
|
|
2934
3021
|
}
|
|
2935
3022
|
if (path === NPM_PROVENANCE_PATH) { await reply(doc.private, { name, version: doc.version, provenance: await provenanceOf() }); return }
|
|
3023
|
+
// Its files paired with those of `otherVersion` (of `otherName`, else the
|
|
3024
|
+
// same package), as Compare and Review pair them, with what their
|
|
3025
|
+
// Overview reads beside them (comparison.ts): the version shown against
|
|
3026
|
+
// the one compared with, or `(empty)`, none, as Review reads a first
|
|
3027
|
+
// version. Read of both, each package's access checked again once worked out.
|
|
3028
|
+
if (path === NPM_COMPARISON_PATH) {
|
|
3029
|
+
const old = otherSpec === '(empty)' ? null : found(await readNpmVersion(otherName, otherSpec, canReadPrivateNpm(reader, otherName), controller.signal, keptVersions))
|
|
3030
|
+
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
3031
|
+
const loads = [doc, ...old ? [old] : []].map(loadNpmFiles)
|
|
3032
|
+
let body
|
|
3033
|
+
try {
|
|
3034
|
+
const [files, oldFiles] = await Promise.all(loads.map(load => load.result))
|
|
3035
|
+
body = await comparisonBody(old ? npmComparedSide(old.dist.integrity, oldFiles!) : emptySide(), npmComparedSide(doc.dist.integrity, files!))
|
|
3036
|
+
} finally { for (const load of loads) load.release() }
|
|
3037
|
+
const current = await npmReader(deps, cookie, otherName === name ? [name] : [name, otherName])
|
|
3038
|
+
if (current == null) { body.stream.destroy(); sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
3039
|
+
if ((doc.private && !canReadPrivateNpm(current, name)) || (old?.private && !canReadPrivateNpm(current, otherName))) {
|
|
3040
|
+
body.stream.destroy(); sendJson(res, 404, { error: 'package-not-found' }); return
|
|
3041
|
+
}
|
|
3042
|
+
if (!res.destroyed) await sendEncoded(req, res, body, 'application/json')
|
|
3043
|
+
return
|
|
3044
|
+
}
|
|
2936
3045
|
// Every one of its files that needs it pretty-printed (pretty-print.ts
|
|
2937
3046
|
// npmAll), or, against another version, the files that changed between
|
|
2938
3047
|
// them, both sides' (compared), a line each as it is formatted, or found
|
|
@@ -2963,7 +3072,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2963
3072
|
if (!(await recheck(isPrivate))) return
|
|
2964
3073
|
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2965
3074
|
await sendLines(res, lines, async () => {
|
|
2966
|
-
const current = await npmReader(deps, cookie)
|
|
3075
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
2967
3076
|
return current != null && (!isPrivate || canReadPrivateNpm(current, name))
|
|
2968
3077
|
})
|
|
2969
3078
|
return
|
|
@@ -3073,13 +3182,16 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3073
3182
|
}
|
|
3074
3183
|
|
|
3075
3184
|
// Public mode probe — lets a client detect the managed protocol up front.
|
|
3185
|
+
// It also sends the session, sparing the client's GET /api/auth/session;
|
|
3186
|
+
// a failed read leaves that request to the client, never failing the probe.
|
|
3076
3187
|
if (path === CONFIG_PATH) {
|
|
3077
3188
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3078
3189
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
3190
|
+
const current = await currentSession(config, db, cookie).catch((err) => { console.warn('managed: config session read failed:', err); return null })
|
|
3079
3191
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
3080
3192
|
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
3081
3193
|
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
3082
|
-
} : {}) } })
|
|
3194
|
+
} : {}) }, ...(current ? { session: current.body } : {}) }, current?.headers)
|
|
3083
3195
|
return
|
|
3084
3196
|
}
|
|
3085
3197
|
if (viewing && underPrefix(path, VIEW_PREFIXES) && !viewAllows(method, path)) { sendViewOnly(res); return }
|
|
@@ -3122,22 +3234,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3122
3234
|
res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
|
|
3123
3235
|
res.end(); return
|
|
3124
3236
|
}
|
|
3125
|
-
// Who am I? While an admin views as another user, that user, plus the
|
|
3126
|
-
// admin as `viewer`. A view that has ended (with the admin's session or
|
|
3127
|
-
// role, or the viewed account) is cleared, returning to the own session.
|
|
3128
3237
|
if (path === SESSION_PATH) {
|
|
3129
3238
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3130
|
-
const
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
if (ended) s = await readSession(config, db, cookie, now, { own: true })
|
|
3134
|
-
const headers: Record<string, string> = ended ? { 'set-cookie': clearViewCookie(config) } : {}
|
|
3135
|
-
if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3136
|
-
sendJson(res, 200, {
|
|
3137
|
-
user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
|
|
3138
|
-
csrfToken: s.session.csrfToken,
|
|
3139
|
-
...(s.session.viewer ? { viewer: s.session.viewer } : {}),
|
|
3140
|
-
}, headers)
|
|
3239
|
+
const { body, headers } = await currentSession(config, db, cookie)
|
|
3240
|
+
if (body == null) { sendJson(res, 401, { error: 'unauthenticated' }, headers); return }
|
|
3241
|
+
sendJson(res, 200, body, headers)
|
|
3141
3242
|
return
|
|
3142
3243
|
}
|
|
3143
3244
|
if (path === VIEW_AS_PATH) { await handleViewAs(req, res, deps, cookie); return }
|
|
@@ -3265,9 +3366,13 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
3265
3366
|
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')
|
|
3266
3367
|
return
|
|
3267
3368
|
}
|
|
3268
|
-
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only)$/iu.exec(path)
|
|
3369
|
+
const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download|pretty|rename-diff|type-only|comparison)$/iu.exec(path)
|
|
3269
3370
|
if (bundleRead) {
|
|
3270
3371
|
const id = bundleRead[1]!, part = bundleRead[2]!, { session } = workspaceSession!, { bundleCache, prettyCache } = deps
|
|
3372
|
+
if (part === 'comparison') {
|
|
3373
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3374
|
+
await handleBundleComparison(req, res, deps, session, id, url.searchParams); return
|
|
3375
|
+
}
|
|
3271
3376
|
if (part === 'rename-diff' || part === 'type-only' || (part === 'pretty' && url.searchParams.has('other'))) {
|
|
3272
3377
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
3273
3378
|
if (part === 'rename-diff' && url.searchParams.has('path')) { await handleBundleRenameDiff(req, res, deps, session, id, url.searchParams); return }
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The packages a lockfile of npm's ecosystem locks, as @preventive/lockfile
|
|
2
2
|
// reads package-lock.json and npm-shrinkwrap.json of `lockfileVersion: 3`,
|
|
3
3
|
// pnpm-lock.yaml of pnpm 9 to 12 and Yarn 1's yarn.lock, each with where it
|
|
4
|
-
// is installed from
|
|
4
|
+
// is installed from and whether only devDependencies need it
|
|
5
|
+
// (repository-risk.ts).
|
|
5
6
|
import { LockfileError, parseNpmLockfile } from '@preventive/lockfile/npm.js'
|
|
6
7
|
import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
|
|
7
8
|
import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
|
|
@@ -21,6 +22,11 @@ export interface LockedPackage {
|
|
|
21
22
|
// A mirror's tarball's, as the lockfile has it: npm's package of its name
|
|
22
23
|
// and version only where npm's integrity is among it.
|
|
23
24
|
integrity?: string
|
|
25
|
+
// Where only the importers' devDependencies reach it, as npm's `dev` flag
|
|
26
|
+
// says, and an install of no devDependencies leaves it out: in npm's and
|
|
27
|
+
// pnpm's lockfiles. Yarn 1's says nothing of it, which only the
|
|
28
|
+
// package.json files do, and none of its are.
|
|
29
|
+
dev?: true
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
// A package as each reader resolves one, its name the one it is published
|
|
@@ -29,7 +35,7 @@ type Resolution =
|
|
|
29
35
|
| { type: 'tarball'; tarball: string | undefined; integrity: string | undefined; gitHosted?: boolean }
|
|
30
36
|
| { type: 'git'; repo: string }
|
|
31
37
|
| { type: 'directory' }
|
|
32
|
-
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined }
|
|
38
|
+
interface ReadPackage { name: string; version: string | undefined; resolution: Resolution | undefined; dev?: boolean | undefined }
|
|
33
39
|
|
|
34
40
|
// The packages of a lockfile of `format`, as its reader resolves them.
|
|
35
41
|
// Versions go unchecked against ranges, as only what is locked is asked for.
|
|
@@ -72,14 +78,31 @@ function locked({ name, version, resolution }: ReadPackage): LockedPackage | nul
|
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// The packages a lockfile of `format` (npm, pnpm or yarn, as
|
|
75
|
-
// findRepoLockfiles names them) locks, each once, in the lockfile's order
|
|
76
|
-
//
|
|
81
|
+
// findRepoLockfiles names them) locks, each once, in the lockfile's order:
|
|
82
|
+
// `dev` only where it is wherever it is installed, at each path of npm's
|
|
83
|
+
// tree, and with each of pnpm's peers. Throws the reader's LockfileError or
|
|
84
|
+
// YamlError for one it refuses.
|
|
77
85
|
export function lockedPackages(format: LockfileFormat, text: string): LockedPackage[] {
|
|
78
86
|
const found = new Map<string, LockedPackage>()
|
|
79
87
|
for (const pkg of packagesOf(format, text)) {
|
|
80
88
|
const one = locked(pkg)
|
|
81
|
-
|
|
82
|
-
if (
|
|
89
|
+
if (!one) continue
|
|
90
|
+
if (pkg.dev === true) one.dev = true
|
|
91
|
+
const key = `${one.source}\0${one.name}@${one.version ?? ''}\0${one.from ?? ''}\0${one.integrity ?? ''}`
|
|
92
|
+
const had = found.get(key)
|
|
93
|
+
if (!had) found.set(key, one)
|
|
94
|
+
else if (had.dev && !one.dev) delete had.dev
|
|
83
95
|
}
|
|
84
96
|
return [...found.values()]
|
|
85
97
|
}
|
|
98
|
+
|
|
99
|
+
// Whether a lockfile at `path` is a test's fixture or example: under a
|
|
100
|
+
// directory of tests (`test`, `tests`, `__tests__` and such), in one of
|
|
101
|
+
// fixtures or examples (`fixture`, `fixtures`, `example`, `examples`) inside
|
|
102
|
+
// it, as a test may keep one its readers refuse on purpose.
|
|
103
|
+
const FIXTURES_DIR = /^(?:fixture|example)s?$/u, TESTS_DIR = /^_*tests?_*$/u
|
|
104
|
+
export function isTestFixture(path: string) {
|
|
105
|
+
const dirs = path.split('/').slice(0, -1)
|
|
106
|
+
const tests = dirs.findIndex(dir => TESTS_DIR.test(dir))
|
|
107
|
+
return tests !== -1 && dirs.slice(tests + 1).some(dir => FIXTURES_DIR.test(dir))
|
|
108
|
+
}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
|
|
6
6
|
// then, as a version can be deprecated, its package made private, or itself
|
|
7
7
|
// removed); and, for scans, each package's risk line, for as long as a scan
|
|
8
|
-
// keeps it
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// keeps it, and what a scan read of GitHub, for a day, and of npm's
|
|
9
|
+
// advisories, for an hour (repository-risk.ts). Each store removes its own rows kept longer
|
|
10
|
+
// than that as it writes, at most once an hour a database (pruneKept), so
|
|
11
|
+
// that rows no read will take again don't pile up.
|
|
11
12
|
// The database is an optimization: a failure reads as a miss, and writes
|
|
12
13
|
// nothing.
|
|
13
14
|
import { createHash } from 'node:crypto'
|
|
@@ -18,30 +19,45 @@ import type { UpstreamCacheStore } from './upstream-cache.ts'
|
|
|
18
19
|
// A value kept by key, as JSON, `at` when it was.
|
|
19
20
|
export interface KeptStore<T> {
|
|
20
21
|
read(key: string): Promise<{ at: number; value: T } | null>
|
|
22
|
+
// Those of `keys` kept, by key, in one read of the database.
|
|
23
|
+
readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
|
|
21
24
|
write(key: string, value: T, at: number): Promise<void>
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
// When each database last had
|
|
27
|
+
// When each database last had the rows under each prefix removed.
|
|
25
28
|
const PRUNE_EVERY_MS = 60 * 60_000
|
|
26
29
|
const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
|
|
27
30
|
|
|
31
|
+
// Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
|
|
32
|
+
// hour a database, as rows under it are written: here, and risk's downloads
|
|
33
|
+
// (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
|
|
34
|
+
export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
|
|
35
|
+
const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
|
|
36
|
+
if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
|
|
37
|
+
times.set(prefix, at)
|
|
38
|
+
try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
// Values of `type`, each read for `keptMs` after it was kept.
|
|
29
42
|
function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debug: boolean): KeptStore<T> {
|
|
30
43
|
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: kept ${type} ${what} failed:`, error) }
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (at - (times.get(type) ?? -Infinity) < PRUNE_EVERY_MS) return
|
|
34
|
-
times.set(type, at)
|
|
35
|
-
try { await db.deleteUpstreamCacheEntries(`${type}/`, at - keptMs) } catch (error) { warn('prune', error) }
|
|
36
|
-
}
|
|
44
|
+
// Kept for good (Infinity): none removed.
|
|
45
|
+
const prune = (at: number) => keptMs === Infinity ? Promise.resolve() : pruneKept(db, `${type}/`, keptMs, at, error => { warn('prune', error) })
|
|
37
46
|
// Keys of any length or character, as the database's: hashed.
|
|
38
47
|
const keyOf = (key: string) => `${type}/${createHash('sha256').update(key).digest('hex')}`
|
|
48
|
+
const valueOf = (row: string | null | undefined) => {
|
|
49
|
+
const kept: unknown = JSON.parse(row ?? 'null')
|
|
50
|
+
return plainObject(kept) && typeof kept['at'] === 'number' && 'value' in kept ? { at: kept['at'], value: kept['value'] as T } : null
|
|
51
|
+
}
|
|
39
52
|
return {
|
|
40
53
|
async read(key) {
|
|
54
|
+
try { return valueOf(await db.getUpstreamCacheEntry(keyOf(key))) } catch (error) { warn('read', error); return null }
|
|
55
|
+
},
|
|
56
|
+
async readMany(keys) {
|
|
41
57
|
try {
|
|
42
|
-
const
|
|
43
|
-
return
|
|
44
|
-
} catch (error) { warn('read', error); return
|
|
58
|
+
const rows = await db.getUpstreamCacheEntries(keys.map(keyOf))
|
|
59
|
+
return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
|
|
60
|
+
} catch (error) { warn('read', error); return new Map() }
|
|
45
61
|
},
|
|
46
62
|
async write(key, value, at) {
|
|
47
63
|
try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
|
|
@@ -61,5 +77,13 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
|
|
|
61
77
|
}
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
// Risk lines, each
|
|
80
|
+
// Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
|
|
81
|
+
// past RISK_KEPT_MS as stale (repository-risk.ts).
|
|
65
82
|
export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
|
|
83
|
+
// What a scan read, each read for keptMs (repository-risk.ts): of GitHub, a
|
|
84
|
+
// repository's lockfiles as listed at a commit (`listings`) and a lockfile's
|
|
85
|
+
// packages by its blob (`lockfiles`), for SCAN_READ_KEPT_MS; of npm, a
|
|
86
|
+
// lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; and the
|
|
87
|
+
// repositories a scan in parts told whole (`progress`).
|
|
88
|
+
export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress', keptMs: number, debug = false): KeptStore<T> =>
|
|
89
|
+
keptStore<T>(db, `repository-risk/${type}`, keptMs, debug)
|