@preventive/triage 1.0.0-alpha.42 → 1.0.0-alpha.43
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/managed/report-filter.ts +2 -1
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/out/{chunk-WOTMRDM3.js → chunk-7RU5EDRO.js} +61 -61
- package/out/{chunk-D7AF2JXL.js → chunk-A7M4GV7M.js} +1 -1
- package/out/{chunk-DGVQPIYB.js → chunk-LAAB5SZT.js} +1 -1
- package/out/{chunk-YQRVLRDH.js → chunk-NWZ4PVPN.js} +1 -1
- package/out/{chunk-YP2537WX.js → chunk-OSIMGFUF.js} +1 -1
- package/out/{chunk-Q7ZSIEHP.js → chunk-XOCAPDCM.js} +1 -1
- package/out/client-managed.js +19 -19
- package/out/index.html +2 -2
- package/out/view.js +54 -54
- package/package.json +9 -3
- package/risk/index.d.ts +26 -0
- package/risk/index.js +1 -1
- package/risk/npm/assess.js +60 -14
- package/risk/npm/dependencies.js +11 -3
- package/risk/npm/new-dependency.js +42 -12
- package/risk/npm/registry.js +122 -21
- package/server-managed/http.ts +63 -26
- package/server-managed/locked-packages.ts +18 -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/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 +255 -201
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/sql.ts +1 -1
- package/server-managed/team-reports.ts +6 -11
- package/server-managed/upstream-cache.ts +16 -0
package/server-managed/http.ts
CHANGED
|
@@ -89,7 +89,7 @@ import { mergeLinkGroups } from './link-reports.ts'
|
|
|
89
89
|
import { backfillFindingIds, reportEntries } from '@preventive/report'
|
|
90
90
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
91
91
|
import { pipeline } from 'node:stream/promises'
|
|
92
|
-
import { npmVersionStore, riskLineStore } from './npm-kept.ts'
|
|
92
|
+
import { npmVersionStore, riskLineStore, scanReadStore } from './npm-kept.ts'
|
|
93
93
|
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
94
94
|
import { auditCache, auditedRepos, lastListing } from './upstream-cache.ts'
|
|
95
95
|
import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
|
|
@@ -128,7 +128,7 @@ import { createClient } from '@preventive/upstream/github.js'
|
|
|
128
128
|
import type { ConnectedRepo } from './github-app.ts'
|
|
129
129
|
import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
|
|
130
130
|
import { RepositoryDiscovery } from './repository-discovery.ts'
|
|
131
|
-
import { RISK_KEPT_MS, repositoryRiskLines } from './repository-risk.ts'
|
|
131
|
+
import { RISK_KEPT_MS, SCAN_READ_KEPT_MS, type ScanProgress, repositoryRiskLines } from './repository-risk.ts'
|
|
132
132
|
import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
|
|
133
133
|
import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
|
|
134
134
|
import { clearCookie, endSession, readSession } from './session.ts'
|
|
@@ -151,14 +151,14 @@ import { hashToken, randomToken, safeEqual } from './crypto.ts'
|
|
|
151
151
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
152
152
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
153
153
|
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'
|
|
154
|
+
import { NpmPackageError, type NpmReader, type NpmVersionDocument, canReadPrivateNpm, npmFileRows, npmScopesCount, npmTarballFilename, readNpmVersion, readNpmVersions } from './npm-packages.ts'
|
|
155
155
|
import { loadNpmFiles, loadNpmPackageBody, loadNpmTarball } from './npm-loads.ts'
|
|
156
156
|
import { computeBundleDiff } from '../common/bundle-compare-diff.js'
|
|
157
157
|
import type { ReviewChange, ReviewContext, ReviewInput } from './dependency-reviews.ts'
|
|
158
158
|
import { allowedReviewVerdicts, isReviewableFile, npmReviewVerdictLimit, npmTakenDown } from '../common/dependency-reviews.js'
|
|
159
159
|
import { type NpmAdvisoryOptions, npmAdvisories, npmCommitTags, npmDownloads, npmGithubStats, npmKnown, npmLatestRepo, npmSocketReport } from './npm-insights.ts'
|
|
160
160
|
import { npmProvenance } from './npm-provenance.ts'
|
|
161
|
-
import { npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
161
|
+
import { npmReadyScanDownload, npmReadyScanDownloads, npmRisk, npmRiskRemoved } from './npm-risk.ts'
|
|
162
162
|
import { NpmRiskError } from '../risk/index.js'
|
|
163
163
|
import { type PrettyCache, PrettyError, type PrettyRequest, compared, prettyRequest, prettyText } from './pretty-print.ts'
|
|
164
164
|
import { renameDiffOf, renameDiffRequest, renamedPairs } from './rename-diff.ts'
|
|
@@ -1351,6 +1351,10 @@ async function sendEncoded(req: IncomingMessage, res: ServerResponse, blob: Open
|
|
|
1351
1351
|
// go idle, unread or with no line to send, before the scan is stopped.
|
|
1352
1352
|
const REPO_RISK_RECHECK_MS = 10_000
|
|
1353
1353
|
const REPO_RISK_IDLE_MS = 5 * 60_000
|
|
1354
|
+
// A scan's part on a serverless deployment, within its function's 300s
|
|
1355
|
+
// (vercel.managed.json): past it the scan stops, its last line `continue`,
|
|
1356
|
+
// and the page asks again, from where it stopped (repository-risk.ts).
|
|
1357
|
+
const REPO_RISK_PART_MS = 270_000
|
|
1354
1358
|
|
|
1355
1359
|
// The repositories a scan of risk reads, by its query: every connected one;
|
|
1356
1360
|
// a team's (`team`, its id), each in the team's directories of it; or one
|
|
@@ -1379,6 +1383,14 @@ async function riskRepositories(db: ManagedDb, query: URLSearchParams): Promise<
|
|
|
1379
1383
|
})
|
|
1380
1384
|
}
|
|
1381
1385
|
|
|
1386
|
+
// Where a scan in parts keeps its progress (repository-risk.ts
|
|
1387
|
+
// ScanProgress) under `key`, for a day; none where the page gave no scan id.
|
|
1388
|
+
function scanProgress(deps: ManagedHttpDeps, key: string, scan: string | null) {
|
|
1389
|
+
if (scan === null || !/^[\w-]{1,64}$/u.test(scan)) return undefined
|
|
1390
|
+
const kept = scanReadStore<ScanProgress>(deps.db, 'progress', SCAN_READ_KEPT_MS, deps.config.debug)
|
|
1391
|
+
return { read: () => kept.read(key), write: (progress: ScanProgress) => kept.write(key, progress, Date.now()) }
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1382
1394
|
// GET /api/admin/repositories/risk: every connected repository's npm
|
|
1383
1395
|
// packages, or a team's or one repository's (riskRepositories), as its
|
|
1384
1396
|
// lockfiles lock them at its default branch's head, each assessed by risk
|
|
@@ -1404,8 +1416,19 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1404
1416
|
res.setTimeout(REPO_RISK_IDLE_MS, () => res.destroy())
|
|
1405
1417
|
const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, s.user.id))
|
|
1406
1418
|
const scopes = new Map<string, Promise<boolean>>()
|
|
1419
|
+
const teamScoped = (name: string) => {
|
|
1420
|
+
const scope = npmPackageScope(name)
|
|
1421
|
+
if (scope === null) return Promise.resolve(false)
|
|
1422
|
+
if (!scopes.has(scope)) scopes.set(scope, deps.db.hasTeamNpmScope(scope))
|
|
1423
|
+
return scopes.get(scope)!
|
|
1424
|
+
}
|
|
1407
1425
|
const versions = npmVersionStore(deps.db, deps.config.debug)
|
|
1426
|
+
const stores = { db: deps.db, npmCache: deps.npmCacheStorage, debug: deps.config.debug }
|
|
1408
1427
|
const lines = repositoryRiskLines({
|
|
1428
|
+
// Which repositories a scan in parts told whole, by the admin, the id the
|
|
1429
|
+
// page gives the scan (`scan`) and its scope, for the next part to go on
|
|
1430
|
+
// past them.
|
|
1431
|
+
progress: scanProgress(deps, `${s.user.id}\0${query.get('scan')}\0${query.get('team') ?? ''}\0${query.get('repo') ?? ''}`, query.get('scan')),
|
|
1409
1432
|
repos: () => Promise.resolve(repos),
|
|
1410
1433
|
async open(repo) {
|
|
1411
1434
|
const reader = await browser.reader(repo)
|
|
@@ -1418,21 +1441,32 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1418
1441
|
recheck: async () => { await reader.recheckAccess() },
|
|
1419
1442
|
}
|
|
1420
1443
|
},
|
|
1421
|
-
teamScoped
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1444
|
+
teamScoped,
|
|
1445
|
+
// Each package's downloads readied before it is assessed
|
|
1446
|
+
// (scan-downloads.ts), so that none waits on npm's downloads API; a
|
|
1447
|
+
// team's own not asked, as risk asks none of theirs.
|
|
1448
|
+
downloads: {
|
|
1449
|
+
async atOnce(names, ready, signal) {
|
|
1450
|
+
const asked: string[] = []
|
|
1451
|
+
for (const name of names) if (await teamScoped(name)) ready(name); else asked.push(name)
|
|
1452
|
+
await npmReadyScanDownloads(asked, ready, stores, signal)
|
|
1453
|
+
},
|
|
1454
|
+
one: (name, signal) => npmReadyScanDownload(name, stores, signal),
|
|
1426
1455
|
},
|
|
1427
1456
|
// Public versions' documents kept in the database (npm-kept.ts), as the
|
|
1428
1457
|
// viewer keeps them: whether npm still has one, its whole document tells
|
|
1429
1458
|
// risk.
|
|
1430
1459
|
version: async (name, version, signal) => found(await readNpmVersion(name, version, true, signal, versions)),
|
|
1431
|
-
assess: (doc,
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1460
|
+
assess: (doc, ownScope, signal) => npmRisk(doc, 'pick', { ...stores, teamScoped: ownScope, privileged: true, signal, spare: true }),
|
|
1461
|
+
removed: (name, version, ownScope, signal) => npmRiskRemoved(name, version, { ...stores, teamScoped: ownScope, privileged: true, signal }),
|
|
1462
|
+
// Kept for good, a scan telling one past RISK_KEPT_MS at once, as stale.
|
|
1463
|
+
risks: riskLineStore(deps.db, Infinity, deps.config.debug),
|
|
1464
|
+
// What it read of GitHub kept a day, for a scan answered in parts to go
|
|
1465
|
+
// on from where the last stopped, on whichever instance answers the next.
|
|
1466
|
+
listings: scanReadStore(deps.db, 'listings', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
1467
|
+
lockfiles: scanReadStore(deps.db, 'lockfiles', SCAN_READ_KEPT_MS, deps.config.debug),
|
|
1468
|
+
// And each lockfile's advisories, for as long as a scan tells them.
|
|
1469
|
+
audits: scanReadStore(deps.db, 'advisories', RISK_KEPT_MS, deps.config.debug),
|
|
1436
1470
|
// As a bundle's advisories are asked (bundle-advisories.ts), npm's registry
|
|
1437
1471
|
// and, for packages anyone may read, OSV: no repository's. Each lockfile's
|
|
1438
1472
|
// audit is bounded on its own, for one npm doesn't answer to fail alone and
|
|
@@ -1444,7 +1478,7 @@ async function handleRepositoryRisk(res: ServerResponse, deps: ManagedHttpDeps,
|
|
|
1444
1478
|
if (audited.status !== 200) throw new NpmPackageError(502, audited.body.error)
|
|
1445
1479
|
return audited.body
|
|
1446
1480
|
},
|
|
1447
|
-
}, controller.signal)
|
|
1481
|
+
}, controller.signal, undefined, { partMs: deps.config.serverless ? REPO_RISK_PART_MS : null })
|
|
1448
1482
|
let checked = Date.now()
|
|
1449
1483
|
await sendLines(res, lines, async () => {
|
|
1450
1484
|
if (deps.isShuttingDown()) return false
|
|
@@ -2614,12 +2648,15 @@ async function handleSetTeamNpmScopes(req: IncomingMessage, res: ServerResponse,
|
|
|
2614
2648
|
sendJson(res, 200, { ok: true, scopes: (await deps.db.listTeamNpmScopes())[teamId] ?? [] })
|
|
2615
2649
|
}
|
|
2616
2650
|
|
|
2617
|
-
// Who reads npm packages now: a session with workspace access, its role and
|
|
2618
|
-
//
|
|
2619
|
-
|
|
2651
|
+
// Who reads npm packages now: a session with workspace access, its role and,
|
|
2652
|
+
// where they count for any of `names` it is asked about (npmScopesCount), the
|
|
2653
|
+
// npm scopes of its visible teams, else none: none are read for an unscoped
|
|
2654
|
+
// package, an admin or a manager, or an answer checked again that is public.
|
|
2655
|
+
// Null for none.
|
|
2656
|
+
async function npmReader(deps: ManagedHttpDeps, cookie: string | undefined, names: readonly string[]): Promise<NpmReader | null> {
|
|
2620
2657
|
const s = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
2621
2658
|
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 }
|
|
2659
|
+
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
2660
|
}
|
|
2624
2661
|
|
|
2625
2662
|
// What a registry read found, or a 404 for the package (handleNpm's catch
|
|
@@ -2636,7 +2673,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2636
2673
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2637
2674
|
res.on('close', onClose)
|
|
2638
2675
|
try {
|
|
2639
|
-
const reader = await npmReader(deps, cookie)
|
|
2676
|
+
const reader = await npmReader(deps, cookie, reviews.flatMap(review => [review.package, ...review.basePackage ? [review.basePackage] : []]))
|
|
2640
2677
|
if (!reader || reader.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2641
2678
|
const documents = new Map<string, Promise<NpmVersionDocument | null>>()
|
|
2642
2679
|
// Public versions' documents as the viewer keeps them (npm-kept.ts), a
|
|
@@ -2650,7 +2687,7 @@ async function readableNpmReviews<T extends ReviewContext>(res: ServerResponse,
|
|
|
2650
2687
|
const packages = await Promise.all(reviews.map(review => Promise.all([
|
|
2651
2688
|
read(review.package, review.version), ...(review.basePackage ? [read(review.basePackage, review.baseVersion!)] : []),
|
|
2652
2689
|
])))
|
|
2653
|
-
const current = await npmReader(deps, cookie)
|
|
2690
|
+
const current = await npmReader(deps, cookie, packages.flat().flatMap(doc => doc?.private ? [doc.name] : []))
|
|
2654
2691
|
if (!current || current.userId !== user) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
|
|
2655
2692
|
return reviews.filter((_, index) => packages[index]!.every(doc => doc && (!doc.private || canReadPrivateNpm(current, doc.name))))
|
|
2656
2693
|
} catch (error) {
|
|
@@ -2760,7 +2797,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2760
2797
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
2761
2798
|
res.on('close', onClose)
|
|
2762
2799
|
try {
|
|
2763
|
-
const reader = await npmReader(deps, cookie)
|
|
2800
|
+
const reader = await npmReader(deps, cookie, [context.package, ...context.basePackage ? [context.basePackage] : []])
|
|
2764
2801
|
if (!reader || reader.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2765
2802
|
const kept = npmVersionStore(deps.db, deps.config.debug)
|
|
2766
2803
|
const read = (pkg: string, spec: string) => readNpmVersion(pkg, spec, canReadPrivateNpm(reader, pkg), controller.signal, kept)
|
|
@@ -2791,7 +2828,7 @@ async function handleNpmReviews(req: IncomingMessage, res: ServerResponse, deps:
|
|
|
2791
2828
|
}
|
|
2792
2829
|
const limit = change?.action === 'publish' && change.final && change.final.status !== 'reject'
|
|
2793
2830
|
? await npmReviewPublicationLimit(deps, reader, doc, controller.signal) : 'accept'
|
|
2794
|
-
const current = await npmReader(deps, cookie)
|
|
2831
|
+
const current = await npmReader(deps, cookie, [doc, base].flatMap(item => item?.private ? [item.name] : []))
|
|
2795
2832
|
if (!current || current.userId !== session.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2796
2833
|
if ([doc, base].some(item => item?.private && !canReadPrivateNpm(current, item.name))) { sendJson(res, 404, { error: 'package-not-found' }); return }
|
|
2797
2834
|
if (!change) {
|
|
@@ -2834,7 +2871,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2834
2871
|
}
|
|
2835
2872
|
const diffed = path === NPM_RENAME_DIFF_PATH && params.has('path') ? renameDiffRequest(params) : null
|
|
2836
2873
|
if (path === NPM_RENAME_DIFF_PATH && params.has('path') && !diffed) { sendJson(res, 400, { error: 'bad-file' }); return }
|
|
2837
|
-
const reader = await npmReader(deps, cookie)
|
|
2874
|
+
const reader = await npmReader(deps, cookie, [name])
|
|
2838
2875
|
if (reader == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
|
|
2839
2876
|
const controller = new AbortController()
|
|
2840
2877
|
const onClose = () => { if (!res.writableEnded) controller.abort() }
|
|
@@ -2842,7 +2879,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2842
2879
|
// A reader who lost their session, or their private access to an answer
|
|
2843
2880
|
// that needed it, while the registry was asked gets nothing.
|
|
2844
2881
|
const recheck = async (isPrivate: boolean) => {
|
|
2845
|
-
const current = await npmReader(deps, cookie)
|
|
2882
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
2846
2883
|
if (current == null) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
|
|
2847
2884
|
if (isPrivate && !canReadPrivateNpm(current, name)) { sendJson(res, 404, { error: 'package-not-found' }); return false }
|
|
2848
2885
|
return !res.destroyed
|
|
@@ -2963,7 +3000,7 @@ async function handleNpm(req: IncomingMessage, res: ServerResponse, deps: Manage
|
|
|
2963
3000
|
if (!(await recheck(isPrivate))) return
|
|
2964
3001
|
res.setTimeout(NPM_RESPONSE_IDLE_MS, () => res.destroy())
|
|
2965
3002
|
await sendLines(res, lines, async () => {
|
|
2966
|
-
const current = await npmReader(deps, cookie)
|
|
3003
|
+
const current = await npmReader(deps, cookie, isPrivate ? [name] : [])
|
|
2967
3004
|
return current != null && (!isPrivate || canReadPrivateNpm(current, name))
|
|
2968
3005
|
})
|
|
2969
3006
|
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,20 @@ 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
|
}
|
|
@@ -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)
|
|
@@ -43,7 +43,7 @@ export class NpmPackageError extends Error {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Who reads, as the session has it now: the role, and the scopes of the
|
|
46
|
-
// visible teams they are a member of.
|
|
46
|
+
// visible teams they are a member of, where those count (npmScopesCount).
|
|
47
47
|
export interface NpmReader { role: Role; scopes: ReadonlySet<string>; userId: string }
|
|
48
48
|
|
|
49
49
|
// Whether a reader may have a package read with the server's token.
|
|
@@ -53,8 +53,19 @@ export function canReadPrivateNpm(reader: NpmReader, name: string): boolean {
|
|
|
53
53
|
return scope !== null && reader.scopes.has(scope)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Whether a reader's team scopes count for any of `names`, as
|
|
57
|
+
// canReadPrivateNpm reads them: a scoped one, read by someone below manager.
|
|
58
|
+
export const npmScopesCount = (role: Role, names: readonly string[]): boolean =>
|
|
59
|
+
role !== 'admin' && role !== 'manage' && names.some(name => npmPackageScope(name) !== null)
|
|
60
|
+
|
|
56
61
|
export const npmToken = () => process.env['NPM_TOKEN'] || null
|
|
57
62
|
|
|
63
|
+
// Whether a read of `name` that npm answers none anonymously is asked again
|
|
64
|
+
// with the server's token: by a reader with private access (`privileged`),
|
|
65
|
+
// where there is a token and the package is scoped, as npm's private packages
|
|
66
|
+
// always are. Else the anonymous answer is the reader's.
|
|
67
|
+
export const npmTokenReads = (name: string, privileged: boolean): boolean => privileged && npmToken() !== null && npmPackageScope(name) !== null
|
|
68
|
+
|
|
58
69
|
function registryUrl(name: string, spec?: string): string {
|
|
59
70
|
return `${NPM_REGISTRY}/${name}${spec === undefined ? '' : `/${encodeURIComponent(spec)}`}`
|
|
60
71
|
}
|
|
@@ -138,16 +149,17 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
|
|
|
138
149
|
return json
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
// Asked anonymously first
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// whole document (`whole`) anyone may read is kept
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
// Asked anonymously first, unless its caller knows npm has none for anyone
|
|
153
|
+
// (`anonymous` false): a reader without private access stops there. One
|
|
154
|
+
// with it asks again with the token where npmTokenReads says so, and the
|
|
155
|
+
// answer is private. A whole document (`whole`) anyone may read is kept
|
|
156
|
+
// (publicWholeDocument).
|
|
157
|
+
async function readDocument(name: string, url: string, privileged: boolean,
|
|
158
|
+
{ whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
|
|
159
|
+
const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
|
|
147
160
|
if (open) return { json: open, private: false }
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const json = await registryDocument(url, { ...options, token })
|
|
161
|
+
if (!npmTokenReads(name, privileged)) return null
|
|
162
|
+
const json = await registryDocument(url, { ...options, token: npmToken() })
|
|
151
163
|
return json ? { json, private: true } : null
|
|
152
164
|
}
|
|
153
165
|
|
|
@@ -244,10 +256,12 @@ export function readNpmVersions(name: string, privileged: boolean, signal: Abort
|
|
|
244
256
|
// A package's whole document as npm has it for this reader, for risk
|
|
245
257
|
// (npm-risk.ts), within the bounds and budget every document read here is
|
|
246
258
|
// held to; null where npm has none for this reader, or the name is none npm
|
|
247
|
-
// takes.
|
|
248
|
-
|
|
259
|
+
// takes. With `anonymous` false, where npm is known to have none for anyone,
|
|
260
|
+
// it is asked only with the token (readDocument).
|
|
261
|
+
export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal, { anonymous = true } = {}): Promise<Record<string, unknown> | null> {
|
|
249
262
|
if (!isNpmPackageName(name)) return Promise.resolve(null)
|
|
250
|
-
return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
|
|
263
|
+
return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
|
|
264
|
+
{ accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true, anonymous }))?.json ?? null)
|
|
251
265
|
}
|
|
252
266
|
|
|
253
267
|
const isPublishTime = (value: string | undefined) => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
// - `risk/npm/versions`, what a version's document says, which npm never
|
|
10
10
|
// changes once published: a public package's in the database's upstream
|
|
11
11
|
// cache table, shared by every instance; a private one's in memory.
|
|
12
|
-
// - `risk/npm/
|
|
13
|
-
//
|
|
12
|
+
// - `risk/npm/downloads`, a package's downloads a day, which risk tells as
|
|
13
|
+
// kept for a week: a public package's in the database too, so that another
|
|
14
|
+
// instance, or this one restarted, asks npm's downloads API, which answers
|
|
15
|
+
// some 40 lookups a minute, none of them again that week, each removed a
|
|
16
|
+
// day past it (npm-kept.ts pruneKept); a private one's in memory.
|
|
17
|
+
// - `risk/npm/packages`, `risk/npm/public` and `risk/npm/advisories`, kept an
|
|
18
|
+
// hour or a day by risk itself: in memory.
|
|
14
19
|
// - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
|
|
15
20
|
// read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
|
|
16
21
|
// an assessment kept needs none.
|
|
@@ -28,11 +33,12 @@ import { Buffer } from 'node:buffer'
|
|
|
28
33
|
import { createHash } from 'node:crypto'
|
|
29
34
|
import { buffer } from 'node:stream/consumers'
|
|
30
35
|
import type { CacheStore } from '@preventive/upstream/npm.js'
|
|
31
|
-
import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
|
|
36
|
+
import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
|
|
32
37
|
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
33
38
|
import { loadNpmTarball } from './npm-loads.ts'
|
|
34
|
-
import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
|
|
39
|
+
import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
|
|
35
40
|
import { npmPackageScope } from '../common/managed/npm-packages.js'
|
|
41
|
+
import { pruneKept } from './npm-kept.ts'
|
|
36
42
|
import type { UpstreamCacheStore } from './upstream-cache.ts'
|
|
37
43
|
|
|
38
44
|
const MEMORY_ENTRIES = 2000
|
|
@@ -63,6 +69,10 @@ function remember(key: string, value: unknown) {
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
|
|
72
|
+
// What of a public package is kept in the database, and for how long its
|
|
73
|
+
// downloads are: risk's week, and a day more.
|
|
74
|
+
const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
|
|
75
|
+
const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
|
|
66
76
|
|
|
67
77
|
export interface NpmRiskStores {
|
|
68
78
|
db: UpstreamCacheStore
|
|
@@ -70,16 +80,38 @@ export interface NpmRiskStores {
|
|
|
70
80
|
debug?: boolean | undefined
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// A cache that reads and writes many of a type at once too, as risk's
|
|
84
|
+
// readying of downloads asks (npmReadyDownloads): one read and one write of
|
|
85
|
+
// the database each, not one a key, as a request's queries take turns on its
|
|
86
|
+
// connection (db-neon.ts).
|
|
87
|
+
export interface NpmRiskCache extends CacheStore {
|
|
88
|
+
readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
|
|
89
|
+
writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
// Risk's cache for one package: `isPublic` whether anyone can read it, so
|
|
74
93
|
// that what is kept of it can be shared.
|
|
75
|
-
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean):
|
|
94
|
+
export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
|
|
76
95
|
const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
|
|
77
|
-
|
|
96
|
+
const cache: NpmRiskCache = {
|
|
97
|
+
async readMany(type, keys) {
|
|
98
|
+
if (!isPublic || !IN_DATABASE.has(type)) return new Map(await Promise.all(keys.map(async key => [key, await cache.read(type, key)] as const)))
|
|
99
|
+
try {
|
|
100
|
+
const found = await db.getUpstreamCacheEntries(keys.map(key => `${type}/${key}`))
|
|
101
|
+
return new Map(keys.map(key => { const value = found.get(`${type}/${key}`); return [key, value === undefined ? null : JSON.parse(value)] }))
|
|
102
|
+
} catch (error) { warn('read', error); return new Map() }
|
|
103
|
+
},
|
|
104
|
+
async writeMany(type, entries) {
|
|
105
|
+
if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
|
|
106
|
+
const at = Date.now()
|
|
107
|
+
try { await db.setUpstreamCacheEntries(entries.map(([key, value]) => [`${type}/${key}`, JSON.stringify(value)]), at) } catch (error) { warn('write', error); return }
|
|
108
|
+
if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
|
|
109
|
+
},
|
|
78
110
|
async read(type, key) {
|
|
79
111
|
if (type === 'npm/tarballs') return null
|
|
80
112
|
try {
|
|
81
113
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
|
|
82
|
-
if (isPublic && type
|
|
114
|
+
if (isPublic && IN_DATABASE.has(type)) { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
|
|
83
115
|
} catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
|
|
84
116
|
return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
|
|
85
117
|
},
|
|
@@ -87,11 +119,17 @@ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: b
|
|
|
87
119
|
if (type === 'npm/tarballs') return
|
|
88
120
|
try {
|
|
89
121
|
if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
|
|
90
|
-
if (isPublic &&
|
|
122
|
+
if (isPublic && IN_DATABASE.has(type)) {
|
|
123
|
+
const at = Date.now()
|
|
124
|
+
await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), at)
|
|
125
|
+
if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
91
128
|
} catch (error) { warn('write', error); return }
|
|
92
129
|
remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
|
|
93
130
|
},
|
|
94
131
|
}
|
|
132
|
+
return cache
|
|
95
133
|
}
|
|
96
134
|
|
|
97
135
|
// A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
|
|
@@ -160,8 +198,9 @@ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Pr
|
|
|
160
198
|
// by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
|
|
161
199
|
// where anyone can read its package, OSV's, which alone has some of those
|
|
162
200
|
// npm removed (nx's), a private package's name never sent to OSV. Its
|
|
163
|
-
// package's document read anonymously, else with npm's token where
|
|
164
|
-
//
|
|
201
|
+
// package's document read anonymously, else with npm's token alone where
|
|
202
|
+
// that reads more for this reader (`privileged`, npmTokenReads), and kept as
|
|
203
|
+
// a private package's is; no
|
|
165
204
|
// tarball read, nothing of the version being left. Spare, within
|
|
166
205
|
// MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
|
|
167
206
|
// nothing tells of it.
|
|
@@ -171,7 +210,26 @@ export function npmRiskRemoved(name: string, version: string,
|
|
|
171
210
|
const open = await readNpmPackument(name, false, signal)
|
|
172
211
|
return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
|
|
173
212
|
osv: open !== null, cache: npmRiskCache(stores, false), signal,
|
|
174
|
-
packument: (read, { authorized }) => read === name
|
|
213
|
+
packument: (read, { authorized }) => read === name
|
|
214
|
+
? open || !npmTokenReads(name, privileged && authorized) ? Promise.resolve(open) : readNpmPackument(name, true, signal, { anonymous: false })
|
|
215
|
+
: readNpmPackument(read, privileged && authorized, signal),
|
|
175
216
|
tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
|
|
176
217
|
})
|
|
177
218
|
}
|
|
219
|
+
|
|
220
|
+
// What a scan readies before assessing its packages (repository-risk.ts):
|
|
221
|
+
// their downloads, as risk asks them, kept where a public package's are
|
|
222
|
+
// (npmRiskCache), so that their assessments wait on npm's downloads API for
|
|
223
|
+
// none. At once, those kept and unscoped ones, in bulk, all public, as only a
|
|
224
|
+
// scoped package can be private; each other one on its own
|
|
225
|
+
// (npmReadyScanDownload), kept only where npm counts some, which it does of
|
|
226
|
+
// public packages alone: a private one's none, as of one it has no such
|
|
227
|
+
// package of, isn't kept where public ones are, for its assessment to ask
|
|
228
|
+
// its own where private ones are.
|
|
229
|
+
export const npmReadyScanDownloads = (names: string[], ready: (name: string) => void, stores: NpmRiskStores, signal: AbortSignal) =>
|
|
230
|
+
npmReadyDownloads(names, ready, { cache: npmRiskCache(stores, true), signal })
|
|
231
|
+
export function npmReadyScanDownload(name: string, stores: NpmRiskStores, signal: AbortSignal) {
|
|
232
|
+
const cache = npmRiskCache(stores, true)
|
|
233
|
+
const counted = (value: unknown) => !(value && typeof value === 'object' && 'none' in value)
|
|
234
|
+
return npmReadyDownload(name, { cache: { read: cache.read, write: (type, key, value) => counted(value) ? cache.write(type, key, value) : Promise.resolve() }, signal })
|
|
235
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Items in the order they are added, each taken once by whichever reader asks
|
|
2
|
+
// first: none once closed and all taken, or once `signal` aborts, but those
|
|
3
|
+
// added by then where `drain`, none waited for.
|
|
4
|
+
export interface Queue<T> { add(item: T): void; close(): void; take(): Promise<T | undefined> }
|
|
5
|
+
export function queue<T>(signal: AbortSignal, { drain = false } = {}): Queue<T> {
|
|
6
|
+
const items: T[] = []
|
|
7
|
+
const waiting: (() => void)[] = []
|
|
8
|
+
let closed = false
|
|
9
|
+
const wake = () => { for (const resolve of waiting.splice(0)) resolve() }
|
|
10
|
+
signal.addEventListener('abort', wake, { once: true })
|
|
11
|
+
return {
|
|
12
|
+
add(item) { items.push(item); wake() },
|
|
13
|
+
close() { closed = true; wake() },
|
|
14
|
+
async take() {
|
|
15
|
+
for (;;) {
|
|
16
|
+
if (signal.aborted && !drain) return undefined
|
|
17
|
+
const item = items.shift()
|
|
18
|
+
if (item !== undefined || closed || signal.aborted) return item
|
|
19
|
+
await new Promise<void>(resolve => { waiting.push(resolve) })
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
29
29
|
}
|
|
30
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
31
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
32
|
-
'finding-access-
|
|
32
|
+
'finding-access-v10', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
33
33
|
])).digest('hex')
|
|
34
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
35
35
|
}
|