@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- 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/bundle-metadata.d.ts +15 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +333 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +18 -0
- package/common/dependency-reviews.js +64 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +44 -13
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +19 -27
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +3 -3
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// The managed npm package viewer: a published version's files, read from the
|
|
2
|
+
// registry's tarball, for the Overview and Code tabs the bundle view shows.
|
|
3
|
+
//
|
|
4
|
+
// Anyone with workspace access may read a public package. A private one also
|
|
5
|
+
// needs NPM_TOKEN on the server, and a reader with access to it: an admin or
|
|
6
|
+
// manager, or a member of a visible team listing its scope (team-npm-scopes.ts).
|
|
7
|
+
// For everyone else, a version is public only when the registry answers for
|
|
8
|
+
// it without credentials, asked on every request and never from a cache:
|
|
9
|
+
// upstream's caches, which bundle builds fill using the token, hold no
|
|
10
|
+
// answer here. Its tarball may come from them (npm-loads.ts), as only bytes
|
|
11
|
+
// matching the integrity that anonymous answer gives are served; else it is
|
|
12
|
+
// read without credentials too, and held to that integrity.
|
|
13
|
+
import { Buffer } from 'node:buffer'
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { rm } from 'node:fs/promises'
|
|
16
|
+
import { resolve } from 'node:path'
|
|
17
|
+
import { ArchiveError, type Entry, unpack } from '@preventive/archive/tar.js'
|
|
18
|
+
import { isExactVersion } from '@preventive/upstream/semver.js'
|
|
19
|
+
import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
|
|
20
|
+
import type { Role } from '../common/managed/roles.ts'
|
|
21
|
+
import { npmManifest, npmVersionFields, plainObject, stringRecord } from './npm-manifest.ts'
|
|
22
|
+
import { keepJson, readKeptJson, upstreamCachePath } from './upstream-cache.ts'
|
|
23
|
+
|
|
24
|
+
export { npmManifest, plainObject }
|
|
25
|
+
|
|
26
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
27
|
+
const VERSION_DOCUMENT_BYTES = 8 * 1024 * 1024
|
|
28
|
+
// Abbreviated packuments of packages with thousands of versions run to tens of MiB.
|
|
29
|
+
const PACKUMENT_BYTES = 64 * 1024 * 1024
|
|
30
|
+
// What registry documents being read at once may hold, each counted at its
|
|
31
|
+
// limit until it is read and parsed: four packuments, or 32 version documents.
|
|
32
|
+
const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
|
|
33
|
+
// What a package may unpack to: the files' bytes and their count; the tar
|
|
34
|
+
// stream holding them is bounded in npm-loads.ts. A repository directory's
|
|
35
|
+
// are its own (repository-code.ts).
|
|
36
|
+
export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
|
|
37
|
+
export const MAX_NPM_PACKAGE_FILES = 20_000
|
|
38
|
+
// The entries its tar stream may hold, read before any is counted as a file:
|
|
39
|
+
// a directory's too, though npm packs none.
|
|
40
|
+
export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
|
|
41
|
+
// What the files may take as the viewer's JSON. Escaping grows a text up to
|
|
42
|
+
// six times (a control character becomes `\u0001`), so it is counted before
|
|
43
|
+
// anything is serialized.
|
|
44
|
+
export const MAX_NPM_JSON_LENGTH = 96 * 1024 * 1024
|
|
45
|
+
export class NpmPackageError extends Error {
|
|
46
|
+
status: number
|
|
47
|
+
constructor(status: number, code: string) { super(code); this.status = status }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Who reads, as the session has it now: the role, and the scopes of the
|
|
51
|
+
// visible teams they are a member of, where those count (npmScopesCount).
|
|
52
|
+
export interface NpmReader { role: Role; scopes: ReadonlySet<string>; userId: string }
|
|
53
|
+
|
|
54
|
+
// Whether a reader may have a package read with the server's token.
|
|
55
|
+
export function canReadPrivateNpm(reader: NpmReader, name: string): boolean {
|
|
56
|
+
if (reader.role === 'admin' || reader.role === 'manage') return true
|
|
57
|
+
const scope = npmPackageScope(name)
|
|
58
|
+
return scope !== null && reader.scopes.has(scope)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Whether a reader's team scopes count for any of `names`, as
|
|
62
|
+
// canReadPrivateNpm reads them: a scoped one, read by someone below manager.
|
|
63
|
+
export const npmScopesCount = (role: Role, names: readonly string[]): boolean =>
|
|
64
|
+
role !== 'admin' && role !== 'manage' && names.some(name => npmPackageScope(name) !== null)
|
|
65
|
+
|
|
66
|
+
export const npmToken = () => process.env['NPM_TOKEN'] || null
|
|
67
|
+
|
|
68
|
+
// Whether a read of `name` that npm answers none anonymously is asked again
|
|
69
|
+
// with the server's token: by a reader with private access (`privileged`),
|
|
70
|
+
// where there is a token and the package is scoped, as npm's private packages
|
|
71
|
+
// always are. Else the anonymous answer is the reader's.
|
|
72
|
+
export const npmTokenReads = (name: string, privileged: boolean): boolean => privileged && npmToken() !== null && npmPackageScope(name) !== null
|
|
73
|
+
|
|
74
|
+
function registryUrl(name: string, spec?: string): string {
|
|
75
|
+
return `${NPM_REGISTRY}/${name}${spec === undefined ? '' : `/${encodeURIComponent(spec)}`}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// What an upstream (npm's registry and APIs, Socket, GitHub) answers, read
|
|
79
|
+
// whole: null where it says it has none (`absent`, 404 unless said). Asked
|
|
80
|
+
// without redirects, and given up on after `ms`, 30 seconds unless said, or
|
|
81
|
+
// once `signal` aborts, its abort rethrown as it is; a 502 where it can't be
|
|
82
|
+
// asked, answers otherwise or stops, and `upstream-too-large` past `limit`
|
|
83
|
+
// bytes (readLimited). `answered` sees the response first, its status and
|
|
84
|
+
// headers.
|
|
85
|
+
export async function askUpstream(url: string, { limit, ms = 30_000, signal, absent = [404], answered, ...init }: Omit<RequestInit, 'signal'>
|
|
86
|
+
& { limit: number; ms?: number; signal?: AbortSignal; absent?: number[]; answered?: (res: Response) => void }): Promise<Buffer | null> {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(ms), ...signal ? [signal] : []]) })
|
|
89
|
+
answered?.(res)
|
|
90
|
+
if (absent.includes(res.status)) { await res.body?.cancel(); return null }
|
|
91
|
+
if (!res.ok) { await res.body?.cancel(); throw new NpmPackageError(502, 'upstream-unavailable') }
|
|
92
|
+
return await readLimited(res, limit)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (signal?.aborted || err instanceof NpmPackageError) throw err
|
|
95
|
+
throw new NpmPackageError(502, 'upstream-unavailable')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The same, read as JSON (by `parse`, as one line of ndjson): a 502
|
|
100
|
+
// `upstream-invalid` where it isn't.
|
|
101
|
+
export async function askUpstreamJson(url: string, options: Parameters<typeof askUpstream>[1], parse = (text: string): unknown => JSON.parse(text)): Promise<unknown> {
|
|
102
|
+
const body = await askUpstream(url, options)
|
|
103
|
+
if (body === null) return null
|
|
104
|
+
try { return parse(body.toString('utf8')) } catch { throw new NpmPackageError(502, 'upstream-invalid') }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readLimited(res: Response, limit: number): Promise<Buffer> {
|
|
108
|
+
const declared = Number(res.headers.get('content-length'))
|
|
109
|
+
if (declared > limit) { await res.body?.cancel(); throw new NpmPackageError(502, 'upstream-too-large') }
|
|
110
|
+
const chunks: Uint8Array[] = []
|
|
111
|
+
let size = 0
|
|
112
|
+
for await (const chunk of res.body ?? []) {
|
|
113
|
+
size += chunk.byteLength
|
|
114
|
+
if (size > limit) throw new NpmPackageError(502, 'upstream-too-large')
|
|
115
|
+
chunks.push(chunk)
|
|
116
|
+
}
|
|
117
|
+
return Buffer.concat(chunks)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A registry document, or null where the registry has none for this caller:
|
|
121
|
+
// 404, or 401/403 as it answers some private packages asked anonymously.
|
|
122
|
+
// Credentials go only with `token`; nothing is cached on either side, but for
|
|
123
|
+
// public whole documents (publicWholeDocument).
|
|
124
|
+
async function registryDocument(url: string, { token, accept, limit, signal }: { token: string | null; accept: string; limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
|
|
125
|
+
const json = await askUpstreamJson(url, { headers: { accept, ...(token ? { authorization: `Bearer ${token}` } : {}) }, limit, signal, absent: [401, 403, 404] })
|
|
126
|
+
if (json === null || plainObject(json)) return json
|
|
127
|
+
throw new NpmPackageError(502, 'upstream-invalid')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Where public packages' whole documents are kept, off Vercel: the upstream
|
|
131
|
+
// cache dir, set at startup (index.ts); null keeps none.
|
|
132
|
+
let documentCache: string | null = null
|
|
133
|
+
export const setNpmDocumentCache = (dir: string | null) => { documentCache = dir === null ? null : resolve(dir) }
|
|
134
|
+
|
|
135
|
+
// A package's whole document as npm answers anyone (`url` its own), kept on
|
|
136
|
+
// disk where the server keeps them (setNpmDocumentCache) with npm's ETag, and
|
|
137
|
+
// asked for again each time with it: npm answers 304, and nothing more, while
|
|
138
|
+
// it is unchanged. Null where npm has none for anyone, nothing then kept.
|
|
139
|
+
async function publicWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
|
|
140
|
+
const path = documentCache === null ? null : upstreamCachePath(documentCache, 'npm/packuments', `${name}.json.gz`)
|
|
141
|
+
const kept = path === null ? null : await readKeptJson(path, limit)
|
|
142
|
+
let etag: string | null = null, status = 0
|
|
143
|
+
const body = await askUpstream(url, { headers: { accept: 'application/json', ...kept && { 'if-none-match': kept.etag } }, limit, signal, absent: [304, 401, 403, 404],
|
|
144
|
+
answered: res => { status = res.status; etag = res.headers.get('etag') } })
|
|
145
|
+
if (status === 304 && kept) return kept.json
|
|
146
|
+
if (body === null) {
|
|
147
|
+
if (path !== null && kept) await rm(path, { force: true }).catch(() => {})
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
let json: unknown
|
|
151
|
+
try { json = JSON.parse(body.toString('utf8')) } catch { throw new NpmPackageError(502, 'upstream-invalid') }
|
|
152
|
+
if (!plainObject(json)) throw new NpmPackageError(502, 'upstream-invalid')
|
|
153
|
+
if (path !== null && etag !== null) await keepJson(path, etag, body)
|
|
154
|
+
return json
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Whole documents being asked of npm as anyone asks, by URL and limit:
|
|
158
|
+
// readers asking for one meanwhile share it, as opening a version asks for
|
|
159
|
+
// its package's for its versions, its advisories and its risk at once; each
|
|
160
|
+
// gives up on it alone once its own signal aborts, and it is given up on once
|
|
161
|
+
// they all have. None is asked with the token. Read alike by every reader,
|
|
162
|
+
// none changing it.
|
|
163
|
+
const wholeAsks = new Map<string, { asked: Promise<Record<string, unknown> | null>; controller: AbortController; readers: number }>()
|
|
164
|
+
|
|
165
|
+
function sharedWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
|
|
166
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
167
|
+
const key = `${url} ${limit}`
|
|
168
|
+
let ask = wholeAsks.get(key)
|
|
169
|
+
if (!ask) {
|
|
170
|
+
const controller = new AbortController()
|
|
171
|
+
const created = { asked: publicWholeDocument(name, url, { limit, signal: controller.signal }), controller, readers: 0 }
|
|
172
|
+
const settle = () => { if (wholeAsks.get(key) === created) wholeAsks.delete(key) }
|
|
173
|
+
created.asked.then(settle, settle)
|
|
174
|
+
wholeAsks.set(key, ask = created)
|
|
175
|
+
}
|
|
176
|
+
const held = ask
|
|
177
|
+
held.readers++
|
|
178
|
+
return new Promise((answer, fail) => {
|
|
179
|
+
let left = false
|
|
180
|
+
const leave = () => {
|
|
181
|
+
if (left) return false
|
|
182
|
+
left = true
|
|
183
|
+
held.readers--
|
|
184
|
+
signal.removeEventListener('abort', onAbort)
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
const onAbort = () => {
|
|
188
|
+
if (!leave()) return
|
|
189
|
+
if (held.readers === 0) {
|
|
190
|
+
if (wholeAsks.get(key) === held) wholeAsks.delete(key)
|
|
191
|
+
held.controller.abort(signal.reason)
|
|
192
|
+
}
|
|
193
|
+
fail(signal.reason)
|
|
194
|
+
}
|
|
195
|
+
const answered = (json: Record<string, unknown> | null) => { if (leave()) answer(json) }
|
|
196
|
+
const failed = (err: unknown) => { if (leave()) fail(err) }
|
|
197
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
198
|
+
held.asked.then(answered, failed)
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Asked anonymously first, unless its caller knows npm has none for anyone
|
|
203
|
+
// (`anonymous` false): a reader without private access stops there. One
|
|
204
|
+
// with it asks again with the token where npmTokenReads says so, and the
|
|
205
|
+
// answer is private. A whole document (`whole`) anyone may read is kept
|
|
206
|
+
// (publicWholeDocument), and asked once for those asking at once
|
|
207
|
+
// (sharedWholeDocument).
|
|
208
|
+
async function readDocument(name: string, url: string, privileged: boolean,
|
|
209
|
+
{ whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
|
|
210
|
+
const open = anonymous ? whole ? await sharedWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
|
|
211
|
+
if (open) return { json: open, private: false }
|
|
212
|
+
if (!npmTokenReads(name, privileged)) return null
|
|
213
|
+
const json = await registryDocument(url, { ...options, token: npmToken() })
|
|
214
|
+
return json ? { json, private: true } : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let documentBytes = 0
|
|
218
|
+
|
|
219
|
+
// A `spare` read, one no reader waits on, is made only where it leaves room
|
|
220
|
+
// for two of the largest documents more: readers' own reads come first.
|
|
221
|
+
async function withDocumentBudget<T>(limit: number, read: () => Promise<T>, spare = false): Promise<T> {
|
|
222
|
+
if (documentBytes + limit + (spare ? 2 * PACKUMENT_BYTES : 0) > MAX_DOCUMENT_BYTES) throw new NpmPackageError(429, 'npm-busy')
|
|
223
|
+
documentBytes += limit
|
|
224
|
+
try { return await read() } finally { documentBytes -= limit }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface NpmVersionDocument {
|
|
228
|
+
name: string
|
|
229
|
+
version: string
|
|
230
|
+
private: boolean
|
|
231
|
+
// `shasum`, the tarball's sha1 in hex, where npm tells it: held, with the
|
|
232
|
+
// integrity, to the tarball its files are read from (npm-loads.ts).
|
|
233
|
+
dist: { tarball: string; integrity: string; shasum: string | null; unpackedSize: number | null; fileCount: number | null }
|
|
234
|
+
manifest: Record<string, unknown>
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null
|
|
238
|
+
|
|
239
|
+
// The version `json`, a document of package `name`, tells of.
|
|
240
|
+
function versionDocument(name: string, json: Record<string, unknown>, isPrivate: boolean): NpmVersionDocument {
|
|
241
|
+
const dist = json['dist']
|
|
242
|
+
if (json['name'] !== name || typeof json['version'] !== 'string' || !isNpmPackageSpec(json['version']) || !plainObject(dist)
|
|
243
|
+
|| typeof dist['tarball'] !== 'string' || typeof dist['integrity'] !== 'string'
|
|
244
|
+
|| (dist['shasum'] !== undefined && typeof dist['shasum'] !== 'string')) throw new NpmPackageError(502, 'upstream-invalid')
|
|
245
|
+
return {
|
|
246
|
+
name, version: json['version'], private: isPrivate,
|
|
247
|
+
dist: { tarball: dist['tarball'], integrity: dist['integrity'], shasum: typeof dist['shasum'] === 'string' ? dist['shasum'] : null,
|
|
248
|
+
unpackedSize: count(dist['unpackedSize']), fileCount: count(dist['fileCount']) },
|
|
249
|
+
manifest: npmManifest(json),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Where public versions' documents are kept, of the fields read of them
|
|
254
|
+
// (npm-manifest.ts npmVersionFields), as the database keeps them (npm-kept.ts).
|
|
255
|
+
export interface NpmVersionStore {
|
|
256
|
+
read(name: string, version: string): Promise<{ at: number; json: Record<string, unknown> } | null>
|
|
257
|
+
// Those of `versions` kept, by `name@version`, in one read.
|
|
258
|
+
readMany?(versions: { name: string; version: string }[]): Promise<Map<string, { at: number; json: Record<string, unknown> }>>
|
|
259
|
+
write(name: string, version: string, json: Record<string, unknown>): Promise<void>
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The version a spec names, a dist-tag resolved by the registry, with its
|
|
263
|
+
// dist. Null where the registry has none for this reader. With `store`, an
|
|
264
|
+
// exact version kept there for less than a day (VERSION_KEPT_MS) is read from
|
|
265
|
+
// it, not asked of npm, and a public one npm answers is kept. npm never
|
|
266
|
+
// changes a version's files, but its document can change: a deprecation, the
|
|
267
|
+
// package made private (a scoped one can be), the version removed (which whoever
|
|
268
|
+
// reads it from a store learns elsewhere too: risk does). A day bounds each.
|
|
269
|
+
// One kept with no shasum, as none was before it was kept (npmVersionFields),
|
|
270
|
+
// is asked of npm again, which tells one of each version.
|
|
271
|
+
export const VERSION_KEPT_MS = 24 * 60 * 60_000
|
|
272
|
+
export function readNpmVersion(name: string, spec: string, privileged: boolean, signal: AbortSignal, store?: NpmVersionStore): Promise<NpmVersionDocument | null> {
|
|
273
|
+
if (!isNpmPackageName(name) || !isNpmPackageSpec(spec)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
|
|
274
|
+
// A kept one is read within the same budget as npm's answer.
|
|
275
|
+
return withDocumentBudget(VERSION_DOCUMENT_BYTES, async () => {
|
|
276
|
+
const kept = store && isExactVersion(spec) ? await store.read(name, spec) : null
|
|
277
|
+
const keptDist = kept?.json['dist']
|
|
278
|
+
if (kept && Date.now() - kept.at < VERSION_KEPT_MS && plainObject(keptDist) && typeof keptDist['shasum'] === 'string') {
|
|
279
|
+
try { return versionDocument(name, kept.json, false) } catch {}
|
|
280
|
+
}
|
|
281
|
+
const found = await readDocument(name, registryUrl(name, spec), privileged, { accept: 'application/json', limit: VERSION_DOCUMENT_BYTES, signal })
|
|
282
|
+
if (!found) return null
|
|
283
|
+
const doc = versionDocument(name, found.json, found.private)
|
|
284
|
+
if (store && !found.private) await store.write(name, doc.version, npmVersionFields(found.json))
|
|
285
|
+
return doc
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `store`, where it reads many at once, with the exact ones of `versions`
|
|
290
|
+
// read from it in one read, for readNpmVersion to read each of those from as
|
|
291
|
+
// it asks: a version and the one it is compared with, read together.
|
|
292
|
+
export function readTogether(store: NpmVersionStore, versions: { name: string; version: string }[]): NpmVersionStore {
|
|
293
|
+
const exact = versions.filter(({ version }) => isExactVersion(version))
|
|
294
|
+
if (!store.readMany || exact.length < 2) return store
|
|
295
|
+
const keys = new Set(exact.map(({ name, version }) => `${name}@${version}`))
|
|
296
|
+
const kept = store.readMany(exact)
|
|
297
|
+
return { ...store, async read(name, version) {
|
|
298
|
+
const key = `${name}@${version}`
|
|
299
|
+
return keys.has(key) ? (await kept).get(key) ?? null : store.read(name, version)
|
|
300
|
+
} }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The package's dist-tags and versions, newest published first, from its
|
|
304
|
+
// abbreviated document; with `times`, from its whole one, which alone has
|
|
305
|
+
// when each was published, `times` by version (ISO dates), and `created`,
|
|
306
|
+
// when its first version was, one since unpublished among them: the earliest
|
|
307
|
+
// time it lists, which npm's own `created` can postdate, as a placeholder's
|
|
308
|
+
// does for a package npm took down. With `spare`, as a read no reader waits
|
|
309
|
+
// on (withDocumentBudget). Null where the registry has none for this reader.
|
|
310
|
+
export function readNpmVersions(name: string, privileged: boolean, signal: AbortSignal, { times = false, spare = false } = {}) {
|
|
311
|
+
if (!isNpmPackageName(name)) return Promise.reject(new NpmPackageError(400, 'bad-package'))
|
|
312
|
+
return withDocumentBudget(PACKUMENT_BYTES, async () => {
|
|
313
|
+
const accept = times ? 'application/json' : 'application/vnd.npm.install-v1+json'
|
|
314
|
+
const found = await readDocument(name, registryUrl(name), privileged, { accept, limit: PACKUMENT_BYTES, signal, whole: times })
|
|
315
|
+
if (!found) return null
|
|
316
|
+
const { json } = found
|
|
317
|
+
if (json['name'] !== name || !plainObject(json['versions'])) throw new NpmPackageError(502, 'upstream-invalid')
|
|
318
|
+
const versions = Object.keys(json['versions']).filter(isNpmPackageSpec).toReversed()
|
|
319
|
+
const distTags = Object.fromEntries(Object.entries(stringRecord(json['dist-tags']) ?? {}).filter(([, version]) => versions.includes(version)))
|
|
320
|
+
const listed = { name, private: found.private, distTags, versions }
|
|
321
|
+
if (!times) return listed
|
|
322
|
+
const time = stringRecord(json['time']) ?? {}
|
|
323
|
+
const published = Object.fromEntries(versions.filter(version => isPublishTime(time[version])).map(version => [version, time[version]!]))
|
|
324
|
+
for (const [version, at] of Object.entries(published)) publishTimes.set(`${name}@${version}`, at)
|
|
325
|
+
while (publishTimes.size > PUBLISH_TIMES) publishTimes.delete(publishTimes.keys().next().value!)
|
|
326
|
+
const listedAt = Object.entries(time).filter(([key, at]) => key !== 'modified' && isPublishTime(at)).map(([, at]) => at)
|
|
327
|
+
const created = listedAt.reduce<string | null>((first, at) => first === null || Date.parse(at) < Date.parse(first) ? at : first, null)
|
|
328
|
+
return { ...listed, times: published, created }
|
|
329
|
+
}, spare)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A package's whole document as npm has it for this reader, for risk
|
|
333
|
+
// (npm-risk.ts), within the bounds and budget every document read here is
|
|
334
|
+
// held to; null where npm has none for this reader, or the name is none npm
|
|
335
|
+
// takes. With `anonymous` false, where npm is known to have none for anyone,
|
|
336
|
+
// it is asked only with the token (readDocument).
|
|
337
|
+
export function readNpmPackument(name: string, privileged: boolean, signal: AbortSignal, { anonymous = true } = {}): Promise<Record<string, unknown> | null> {
|
|
338
|
+
if (!isNpmPackageName(name)) return Promise.resolve(null)
|
|
339
|
+
return withDocumentBudget(PACKUMENT_BYTES, async () => (await readDocument(name, registryUrl(name), privileged,
|
|
340
|
+
{ accept: 'application/json', limit: PACKUMENT_BYTES, signal, whole: true, anonymous }))?.json ?? null)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const isPublishTime = (value: string | undefined) => typeof value === 'string' && value.length <= 40 && !Number.isNaN(Date.parse(value))
|
|
344
|
+
|
|
345
|
+
// When each version was published, by `name@version`, as whole documents read
|
|
346
|
+
// with `times` have said: a version keeps its time once published, so it is
|
|
347
|
+
// kept for as long as the server runs, the first noted going first past a
|
|
348
|
+
// bound, and answered only with the version, once its reader's access is.
|
|
349
|
+
const PUBLISH_TIMES = 100_000
|
|
350
|
+
const publishTimes = new Map<string, string>()
|
|
351
|
+
|
|
352
|
+
// Where public versions' publish times are kept beyond the instance, as the
|
|
353
|
+
// database keeps them (npm-kept.ts npmPublishTimeStore).
|
|
354
|
+
export interface NpmPublishTimeStore {
|
|
355
|
+
read(name: string, version: string): Promise<string | null>
|
|
356
|
+
write(name: string, version: string, at: string): Promise<void>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// When `doc` was published: as noted here, else as `store` keeps a public
|
|
360
|
+
// version's, so that another instance, or this one restarted, has it before
|
|
361
|
+
// it reads the whole document again. The store is read once, at once, beside
|
|
362
|
+
// whatever its caller reads meanwhile, a failure read as none; `read` looks
|
|
363
|
+
// here again as it is asked, as the whole document read meanwhile may have
|
|
364
|
+
// noted it by then, and `keep` keeps there the time noted here where it read
|
|
365
|
+
// none.
|
|
366
|
+
export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version' | 'private'>, store?: NpmPublishTimeStore) {
|
|
367
|
+
const noted = () => publishTimes.get(`${doc.name}@${doc.version}`) ?? null
|
|
368
|
+
const kept = store && !doc.private ? store.read(doc.name, doc.version).catch(() => null) : Promise.resolve(null)
|
|
369
|
+
return {
|
|
370
|
+
read: async (): Promise<string | null> => noted() ?? await kept ?? noted(),
|
|
371
|
+
async keep() {
|
|
372
|
+
const at = noted()
|
|
373
|
+
if (at !== null && store && !doc.private && await kept === null) await store.write(doc.name, doc.version, at)
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface NpmPackageFile { path: string; bytes: Uint8Array }
|
|
379
|
+
|
|
380
|
+
// How a tar stream is read (readNpmTar): how far its symlinks may climb above
|
|
381
|
+
// it, and the entries, files and bytes it may hold, a package's unless given.
|
|
382
|
+
export interface TarReading { symlinksAbove?: number; maxEntries?: number; maxFiles?: number; maxBytes?: number }
|
|
383
|
+
|
|
384
|
+
// The regular files of a tar stream, by package path: npm drops the first
|
|
385
|
+
// component (`package/`, or whatever the archive used), and a file at the top
|
|
386
|
+
// is not extracted. The stream is read by @preventive/archive, which refuses
|
|
387
|
+
// one malformed, a name or a symlink that would climb out of it -- a symlink
|
|
388
|
+
// past `symlinksAbove` levels above it, none unless given -- and a name
|
|
389
|
+
// repeated as another entry, and gives up past `maxEntries` before reading
|
|
390
|
+
// on; a file under another top directory at the same path replaces an
|
|
391
|
+
// earlier one, as extraction would. Directories, links and devices hold no
|
|
392
|
+
// file content and are skipped.
|
|
393
|
+
export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0, maxEntries = MAX_NPM_TAR_ENTRIES, maxFiles = MAX_NPM_PACKAGE_FILES,
|
|
394
|
+
maxBytes = MAX_NPM_PACKAGE_BYTES }: TarReading = {}): NpmPackageFile[] {
|
|
395
|
+
let entries: Entry[]
|
|
396
|
+
try { entries = unpack(tar, { maxEntries, symlinksAbove }) }
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
|
|
399
|
+
throw err
|
|
400
|
+
}
|
|
401
|
+
const files = new Map<string, Uint8Array>()
|
|
402
|
+
let total = 0
|
|
403
|
+
for (const { name, type, data } of entries) {
|
|
404
|
+
const slash = name.indexOf('/')
|
|
405
|
+
if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
|
|
406
|
+
const path = name.slice(slash + 1)
|
|
407
|
+
total += data.length - (files.get(path)?.length ?? 0)
|
|
408
|
+
files.set(path, data)
|
|
409
|
+
if (files.size > maxFiles || total > maxBytes) throw new NpmPackageError(413, 'package-too-large')
|
|
410
|
+
}
|
|
411
|
+
return [...files].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
415
|
+
|
|
416
|
+
// A file's text, or null for bytes that are not UTF-8 text.
|
|
417
|
+
export function npmFileText(bytes: Uint8Array): string | null {
|
|
418
|
+
if (bytes.includes(0)) return null
|
|
419
|
+
try { return utf8.decode(bytes) } catch { return null }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// A string's length in JSON: quotes, backslashes and the control characters
|
|
423
|
+
// with a short escape take two characters, the other control characters six.
|
|
424
|
+
function jsonLength(text: string): number {
|
|
425
|
+
let length = text.length + 2
|
|
426
|
+
for (let i = 0; i < text.length; i++) {
|
|
427
|
+
const code = text.codePointAt(i)!
|
|
428
|
+
if (code === 0x22 || code === 0x5c) length += 1
|
|
429
|
+
else if (code < 0x20) length += code >= 0x08 && code <= 0x0d && code !== 0x0b ? 1 : 5
|
|
430
|
+
}
|
|
431
|
+
return length
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A row's brackets, size, null and digest, past its strings.
|
|
435
|
+
const ROW_LENGTH = 80
|
|
436
|
+
|
|
437
|
+
// The files as the viewer gets them: `[path, bytes, text]`, text null for a
|
|
438
|
+
// file that is not UTF-8, which instead carries its bytes' sha256, so a
|
|
439
|
+
// comparison of two versions tells a changed one from an unchanged one. They
|
|
440
|
+
// may take `maxLength` characters of JSON, a package's unless given.
|
|
441
|
+
export function npmFileRows(files: NpmPackageFile[], maxLength = MAX_NPM_JSON_LENGTH): ([string, number, string] | [string, number, null, string])[] {
|
|
442
|
+
let length = 0
|
|
443
|
+
return files.map(({ path, bytes }) => {
|
|
444
|
+
const text = npmFileText(bytes)
|
|
445
|
+
length += ROW_LENGTH + jsonLength(path) + (text === null ? 0 : jsonLength(text))
|
|
446
|
+
if (length > maxLength) throw new NpmPackageError(413, 'package-too-large')
|
|
447
|
+
return text === null ? [path, bytes.byteLength, null, `sha256-${createHash('sha256').update(bytes).digest('base64')}`] : [path, bytes.byteLength, text]
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The tarball's filename as `npm pack` writes it.
|
|
452
|
+
export function npmTarballFilename(name: string, version: string): string {
|
|
453
|
+
return `${name.replace(/^@/u, '').replace('/', '-')}-${version}.tgz`
|
|
454
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// npm's provenance for a public version (npm-packages.ts npmManifest marks
|
|
2
|
+
// one that has it): the SLSA statement npm's registry keeps beside it,
|
|
3
|
+
// saying where the version was built: from which repository and commit, by
|
|
4
|
+
// which workflow, on which builder, in which run, with its entry in
|
|
5
|
+
// Sigstore's transparency log. Only a statement naming this version's
|
|
6
|
+
// tarball (its name, version and sha512 digest) is read; its signature isn't
|
|
7
|
+
// checked here, as npm checks it when the version is published. npm is asked
|
|
8
|
+
// without the server's token, and only about a version it answers for
|
|
9
|
+
// without one (http.ts), each answer kept as npm-insights.ts keeps its own.
|
|
10
|
+
import { Buffer } from 'node:buffer'
|
|
11
|
+
import { kept } from './kept.ts'
|
|
12
|
+
import { NPM_REGISTRY, askUpstreamJson, plainObject } from './npm-packages.ts'
|
|
13
|
+
|
|
14
|
+
const ATTESTATIONS_API = `${NPM_REGISTRY}/-/npm/v1/attestations`
|
|
15
|
+
const ATTESTATIONS_BYTES = 1024 * 1024
|
|
16
|
+
// SLSA's v1 statement, as npm writes it now, and v0.2, as it first did.
|
|
17
|
+
const SLSA = /^https:\/\/slsa\.dev\/provenance\/v(?:1|0\.2)$/u
|
|
18
|
+
const COMMIT = /^[0-9a-f]{40}$/u
|
|
19
|
+
const GITHUB_REPOSITORY = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/u
|
|
20
|
+
|
|
21
|
+
// Where a version was built: its source `repository` (an https URL) and
|
|
22
|
+
// `commit`, the `workflow` file and the `ref` it ran on, the `builder` and
|
|
23
|
+
// the `run` (an https URL), its transparency log entry (`logIndex`), and
|
|
24
|
+
// whose runner built it (`runner`: `github-hosted`, `gitlab-hosted` or
|
|
25
|
+
// `self-hosted`, as its signing certificate says); each null where they
|
|
26
|
+
// don't say.
|
|
27
|
+
export interface NpmProvenance {
|
|
28
|
+
repository: string; commit: string | null; workflow: string | null; ref: string | null
|
|
29
|
+
builder: string | null; run: string | null; logIndex: number | null; runner: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const text = (value: unknown) => typeof value === 'string' && value !== '' && value.length <= 2048 ? value : null
|
|
33
|
+
const https = (value: unknown) => {
|
|
34
|
+
const url = text(value)
|
|
35
|
+
try { return url && new URL(url).protocol === 'https:' ? url : null } catch { return null }
|
|
36
|
+
}
|
|
37
|
+
// `git+https://github.com/org/repo@refs/heads/main` → the repository and
|
|
38
|
+
// ref; GitLab's names no ref (`git+https://gitlab.com/group/project`).
|
|
39
|
+
function sourceOf(uri: unknown) {
|
|
40
|
+
const match = /^git\+(https:\/\/[^@\s]+?)(?:\.git)?(?:@(.+))?$/u.exec(text(uri) ?? '')
|
|
41
|
+
return match ? { repository: https(match[1]), ref: match[2] ?? null } : { repository: null, ref: null }
|
|
42
|
+
}
|
|
43
|
+
// What `keys` lead to in `value`, through objects and arrays.
|
|
44
|
+
const field = (value: unknown, ...keys: string[]): unknown =>
|
|
45
|
+
keys.reduce<unknown>((at, key) => plainObject(at) || Array.isArray(at) ? (at as Record<string, unknown>)[key] : undefined, value)
|
|
46
|
+
|
|
47
|
+
// What a statement says of the build, as v1 or v0.2 says it; null where it
|
|
48
|
+
// names no source repository.
|
|
49
|
+
function readStatement(predicateType: string, predicate: unknown): NpmProvenance | null {
|
|
50
|
+
if (predicateType.endsWith('v1')) {
|
|
51
|
+
const workflow = field(predicate, 'buildDefinition', 'externalParameters', 'workflow')
|
|
52
|
+
const source = field(predicate, 'buildDefinition', 'resolvedDependencies', '0')
|
|
53
|
+
const repository = https(field(workflow, 'repository')) ?? sourceOf(field(source, 'uri')).repository
|
|
54
|
+
if (!repository) return null
|
|
55
|
+
const commit = text(field(source, 'digest', 'gitCommit'))
|
|
56
|
+
return {
|
|
57
|
+
repository, commit: commit && COMMIT.test(commit) ? commit : null, workflow: text(field(workflow, 'path')),
|
|
58
|
+
ref: text(field(workflow, 'ref')) ?? sourceOf(field(source, 'uri')).ref, builder: https(field(predicate, 'runDetails', 'builder', 'id')),
|
|
59
|
+
run: https(field(predicate, 'runDetails', 'metadata', 'invocationId')), logIndex: null, runner: null,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const config = field(predicate, 'invocation', 'configSource')
|
|
63
|
+
const { repository, ref } = sourceOf(field(config, 'uri'))
|
|
64
|
+
if (!repository) return null
|
|
65
|
+
const commit = text(field(config, 'digest', 'sha1'))
|
|
66
|
+
// Its run: GitLab's job URL, or GitHub's as `<run id>-<attempt>`.
|
|
67
|
+
const invocation = field(predicate, 'metadata', 'buildInvocationId')
|
|
68
|
+
const run = /^(\d+)-(\d+)$/u.exec(text(invocation) ?? '')
|
|
69
|
+
const github = GITHUB_REPOSITORY.exec(repository)?.[1]
|
|
70
|
+
return {
|
|
71
|
+
repository, commit: commit && COMMIT.test(commit) ? commit : null, workflow: text(field(config, 'entryPoint')), ref,
|
|
72
|
+
builder: https(field(predicate, 'builder', 'id')),
|
|
73
|
+
run: https(invocation) ?? (run && github ? `https://github.com/${github}/actions/runs/${run[1]}/attempts/${run[2]}` : null), logIndex: null, runner: null,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fulcio's Runner Environment extension (1.3.6.1.4.1.57264.1.11) in the
|
|
78
|
+
// certificate that signed it: its OID, then its value, a short UTF8String
|
|
79
|
+
// in an OCTET STRING, as the CI's token gave it. A builder's id doesn't
|
|
80
|
+
// tell GitLab's own runners from a project's.
|
|
81
|
+
const RUNNER_ENVIRONMENT = Buffer.from('060a2b0601040183bf30010b', 'hex')
|
|
82
|
+
function runnerOf(bundle: unknown): string | null {
|
|
83
|
+
const raw = field(bundle, 'verificationMaterial', 'certificate', 'rawBytes') ?? field(bundle, 'verificationMaterial', 'x509CertificateChain', 'certificates', '0', 'rawBytes')
|
|
84
|
+
if (typeof raw !== 'string') return null
|
|
85
|
+
const der = Buffer.from(raw, 'base64')
|
|
86
|
+
const at = der.indexOf(RUNNER_ENVIRONMENT) + RUNNER_ENVIRONMENT.length
|
|
87
|
+
if (at < RUNNER_ENVIRONMENT.length || der[at] !== 0x04 || der[at + 2] !== 0x0c || der[at + 1] !== der[at + 3]! + 2) return null
|
|
88
|
+
return text(der.subarray(at + 4, at + 4 + der[at + 3]!).toString('utf8'))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function askProvenance(name: string, version: string, integrity: string): Promise<NpmProvenance | null> {
|
|
92
|
+
const json = await askUpstreamJson(`${ATTESTATIONS_API}/${name.replace('/', '%2f')}@${encodeURIComponent(version)}`,
|
|
93
|
+
{ headers: { accept: 'application/json' }, limit: ATTESTATIONS_BYTES })
|
|
94
|
+
if (json === null) return null
|
|
95
|
+
const attestations: unknown[] = Array.isArray(field(json, 'attestations')) ? field(json, 'attestations') as unknown[] : []
|
|
96
|
+
const slsa = attestations.find(item => SLSA.test(text(field(item, 'predicateType')) ?? ''))
|
|
97
|
+
if (!slsa) return null
|
|
98
|
+
// The statement, whatever its length (the answer is bounded as a whole),
|
|
99
|
+
// unlike the fields read out of it.
|
|
100
|
+
const payload = field(slsa, 'bundle', 'dsseEnvelope', 'payload')
|
|
101
|
+
let statement: unknown
|
|
102
|
+
try { statement = JSON.parse(Buffer.from(typeof payload === 'string' ? payload : '', 'base64').toString('utf8')) } catch { return null }
|
|
103
|
+
// Only one naming this version's tarball: its purl, and its digest as the
|
|
104
|
+
// version's integrity has it.
|
|
105
|
+
const purl = `pkg:npm/${name.replace(/^@/u, '%40')}@${version}`
|
|
106
|
+
const digest = /^sha512-(.+)$/u.exec(integrity)?.[1]
|
|
107
|
+
const subjects: unknown[] = Array.isArray(field(statement, 'subject')) ? field(statement, 'subject') as unknown[] : []
|
|
108
|
+
if (!digest || !subjects.some(subject => field(subject, 'name') === purl && field(subject, 'digest', 'sha512') === Buffer.from(digest, 'base64').toString('hex'))) return null
|
|
109
|
+
const provenance = readStatement(text(field(slsa, 'predicateType'))!, field(statement, 'predicate'))
|
|
110
|
+
const logIndex = Number(field(slsa, 'bundle', 'verificationMaterial', 'tlogEntries', '0', 'logIndex'))
|
|
111
|
+
return provenance && { ...provenance, logIndex: Number.isSafeInteger(logIndex) && logIndex >= 0 ? logIndex : null, runner: runnerOf(field(slsa, 'bundle')) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const provenances = kept<NpmProvenance | null>(1000)
|
|
115
|
+
|
|
116
|
+
// npm's provenance for a version of a public package, its tarball's
|
|
117
|
+
// `integrity` as the registry has it; null where npm keeps none for it.
|
|
118
|
+
export function npmProvenance(name: string, version: string, integrity: string): Promise<NpmProvenance | null> {
|
|
119
|
+
return provenances(`${name}@${version}`, () => askProvenance(name, version, integrity))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One kept, asking for nothing; undefined where none is.
|
|
123
|
+
export const keptNpmProvenance = (name: string, version: string) => provenances.peek(`${name}@${version}`)
|