@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51
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 +3 -0
- package/common/managed/finding-detail.js +66 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/npm-packages.d.ts +7 -0
- package/common/managed/npm-packages.js +51 -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 +359 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +6 -0
- package/common/managed/team-app.js +47 -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-2K6UN3PC.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-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7JBSSRD7.js +3 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FMXWXDR4.js +36 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HT2SSA3V.js +1 -0
- package/out/chunk-IPAH4LKF.js +232 -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-KOGNQDSJ.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-VDRXHIHN.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-WRAJVD35.js +1 -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 +364 -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 +23 -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 +76 -0
- package/server-managed/comparison.ts +52 -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 +468 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +143 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +98 -0
- package/server-managed/dependency-reviews.ts +364 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +376 -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 +136 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5503 -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/listed-activity.ts +39 -0
- package/server-managed/locked-packages.ts +152 -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 +225 -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-patch.ts +325 -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 +351 -0
- package/server-managed/pull-dependency-reviews.ts +334 -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 +448 -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 +166 -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 +212 -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 +149 -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 +319 -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,325 @@
|
|
|
1
|
+
// A pnpm patch of an npm package version (patchedDependencies), as its
|
|
2
|
+
// dependency review reads it (pull-dependency-reviews.ts): its file, which a
|
|
3
|
+
// lockfile names (locked-packages.ts `patchPath`), read of a repository at a
|
|
4
|
+
// commit, and held to the hash the lockfile names it by, as pnpm hashes it,
|
|
5
|
+
// a sha256 in hex from pnpm 10 and an md5 in base32 before, of its text with
|
|
6
|
+
// its CRLFs read as LFs; and applied to the version's files as pnpm applies
|
|
7
|
+
// it as it installs it (patch-package's applyPatch, which pnpm 9, 10 and 11
|
|
8
|
+
// bundle): each file it deletes, creates, renames or changes, a change's
|
|
9
|
+
// hunks each found in the file as it was, where its header says, else as
|
|
10
|
+
// many as FUZZ lines either side, its lines the same but for the space they
|
|
11
|
+
// end in, and those it keeps kept as the file has them. A patch of a file's
|
|
12
|
+
// bytes (git's binary patch), or of a file outside the package, isn't read,
|
|
13
|
+
// and one that doesn't apply fails as a whole; what a patch changes of a
|
|
14
|
+
// file's mode is left out, as the files it reads have none.
|
|
15
|
+
import { Buffer } from 'node:buffer'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
18
|
+
import { isPatchHash, isPatchPath } from '../common/managed/npm-packages.js'
|
|
19
|
+
import { GithubApiError } from './github-app.ts'
|
|
20
|
+
import { upstreamGithub } from './github-errors.ts'
|
|
21
|
+
import { type Lru, lru } from './lru.ts'
|
|
22
|
+
import { NpmPackageError, type NpmPackageFile, npmFileText } from './npm-packages.ts'
|
|
23
|
+
import type { GithubAsked } from './pull-dependencies.ts'
|
|
24
|
+
|
|
25
|
+
// Where a patch is read: its file in a repository (by its id) at a commit,
|
|
26
|
+
// from the repository's root; and the patch, by its hash besides.
|
|
27
|
+
export interface PatchSource { repoId: number; commit: string; path: string }
|
|
28
|
+
export interface NpmPatch extends PatchSource { hash: string }
|
|
29
|
+
|
|
30
|
+
// The most a patch's file may hold, and how many lines either side of where a
|
|
31
|
+
// hunk says its lines are they are looked for.
|
|
32
|
+
export const MAX_PATCH_BYTES = 8 * 1024 * 1024
|
|
33
|
+
const FUZZ = 20
|
|
34
|
+
|
|
35
|
+
const COMMIT = /^[\da-f]{40}$/u
|
|
36
|
+
|
|
37
|
+
// The patch a request names, by its hash (`hash`), its repository's id
|
|
38
|
+
// (`repoId`, a number or its digits), the commit and its file's path; null
|
|
39
|
+
// where it names none of where it is, and 'invalid' where it names one
|
|
40
|
+
// otherwise.
|
|
41
|
+
export function requestedPatch({ hash, repoId, commit, path }: { hash: unknown; repoId: unknown; commit: unknown; path: unknown }): NpmPatch | null | 'invalid' {
|
|
42
|
+
if ([repoId, commit, path].every(field => field == null)) return null
|
|
43
|
+
const id = typeof repoId === 'string' && /^[1-9]\d{0,15}$/u.test(repoId) ? Number(repoId) : repoId
|
|
44
|
+
if (!isPatchHash(hash) || !Number.isSafeInteger(id) || (id as number) <= 0 || typeof commit !== 'string' || !COMMIT.test(commit)
|
|
45
|
+
|| !isPatchPath(path)) return 'invalid'
|
|
46
|
+
return { hash, repoId: id as number, commit, path }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// RFC 4648's base32 of `bytes`, in lowercase and unpadded, as pnpm 9 spells
|
|
50
|
+
// an md5.
|
|
51
|
+
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
|
|
52
|
+
function base32(bytes: Uint8Array) {
|
|
53
|
+
let bits = 0, spelled = '', value = 0
|
|
54
|
+
for (const byte of bytes) {
|
|
55
|
+
value = ((value << 8) | byte) & 0xfff
|
|
56
|
+
bits += 8
|
|
57
|
+
for (; bits >= 5; bits -= 5) spelled += BASE32[(value >>> (bits - 5)) & 31]
|
|
58
|
+
}
|
|
59
|
+
return bits > 0 ? spelled + BASE32[(value << (5 - bits)) & 31] : spelled
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether `text` is the patch `hash` names: of its text with its CRLFs as
|
|
63
|
+
// LFs, as pnpm reads it, or as it is.
|
|
64
|
+
export function isPatchOf(text: string, hash: string): boolean {
|
|
65
|
+
return [...new Set([text.replaceAll('\r\n', '\n'), text])].some(read => hash.length === 64
|
|
66
|
+
? createHash('sha256').update(read).digest('hex') === hash : base32(createHash('md5').update(read).digest()) === hash)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A change a patch makes to a file at `path` of the package: deleting it,
|
|
70
|
+
// creating it, renaming it from another (`from`), and its hunks, as
|
|
71
|
+
// patch-package reads them: where its header says its lines are (`at`, the
|
|
72
|
+
// first's index, as it reads `-0`, the first line), how many it finds
|
|
73
|
+
// (`length`), and its lines, each without its LF, in runs of a kind
|
|
74
|
+
// (`parts`): those it keeps, removes or adds, the last of a side told where
|
|
75
|
+
// the file has no LF after it (`unended`).
|
|
76
|
+
export interface HunkPart { kind: 'context' | 'deletion' | 'insertion'; lines: string[]; unended?: true }
|
|
77
|
+
export interface PatchHunk { at: number; length: number; parts: HunkPart[] }
|
|
78
|
+
export interface PatchedFile { path: string; from?: string; deleted?: true; created?: true; hunks: PatchHunk[] }
|
|
79
|
+
|
|
80
|
+
const unreadable = () => new NpmPackageError(422, 'patch-unreadable')
|
|
81
|
+
|
|
82
|
+
// A path as git writes it, unquoted where quoted (as C writes a string, its
|
|
83
|
+
// bytes past ASCII in octal); null where it is quoted otherwise.
|
|
84
|
+
const ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
|
|
85
|
+
function unquoted(path: string): string | null {
|
|
86
|
+
if (!path.startsWith('"')) return path
|
|
87
|
+
const bytes: number[] = []
|
|
88
|
+
for (let at = 1; at < path.length; at++) {
|
|
89
|
+
const char = path[at]!
|
|
90
|
+
if (char === '"') return at === path.length - 1 ? new TextDecoder().decode(Uint8Array.from(bytes)) : null
|
|
91
|
+
if (char !== '\\') { bytes.push(...Buffer.from(char)); continue }
|
|
92
|
+
const next = path[++at] ?? ''
|
|
93
|
+
const octal = /^[0-3][0-7]{2}/u.exec(path.slice(at))?.[0]
|
|
94
|
+
if (octal) { bytes.push(Number.parseInt(octal, 8)); at += 2 }
|
|
95
|
+
else if (next in ESCAPES) bytes.push(ESCAPES[next]!)
|
|
96
|
+
else return null
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A side's path in the package, as a `---` or `+++` line names it after its
|
|
102
|
+
// prefix (`a/` or `b/`), git's tab after one with a space in it left out;
|
|
103
|
+
// null for none (`/dev/null`).
|
|
104
|
+
function sidePath(named: string, prefix: string): string | null {
|
|
105
|
+
const path = unquoted(named.endsWith('\t') ? named.slice(0, -1) : named)
|
|
106
|
+
if (path === '/dev/null') return null
|
|
107
|
+
if (path === null || !path.startsWith(prefix)) throw unreadable()
|
|
108
|
+
return path.slice(prefix.length)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The paths a `diff --git` line names, where they are the same, as git
|
|
112
|
+
// writes them for a file neither renamed nor copied: `a/<path> b/<path>`.
|
|
113
|
+
function gitPath(named: string): string | null {
|
|
114
|
+
if (named.startsWith('"')) {
|
|
115
|
+
const quoted = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/u.exec(named)
|
|
116
|
+
const [a, b] = quoted ? [unquoted(quoted[1]!), unquoted(quoted[2]!)] : []
|
|
117
|
+
return a?.startsWith('a/') && b?.startsWith('b/') && a.slice(2) === b.slice(2) ? a.slice(2) : null
|
|
118
|
+
}
|
|
119
|
+
const length = (named.length - 5) / 2
|
|
120
|
+
const path = named.slice(2, 2 + length)
|
|
121
|
+
return Number.isInteger(length) && named.startsWith('a/') && named.slice(2 + length) === ` b/${path}` ? path : null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The hunk whose `@@` line is at `at` of `lines`, those before `end` read,
|
|
125
|
+
// and the line after it, as patch-package reads it: a line of its own
|
|
126
|
+
// kind, else of those it keeps where it is empty, or a CR alone.
|
|
127
|
+
function readHunk(lines: string[], at: number, end: number): { hunk: PatchHunk; next: number } {
|
|
128
|
+
const range = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/u.exec(lines[at++]!)
|
|
129
|
+
if (!range) throw unreadable()
|
|
130
|
+
let news = Number(range[4] ?? 1), olds = Number(range[2] ?? 1)
|
|
131
|
+
const hunk: PatchHunk = { at: Math.max(Number(range[1]), 1) - 1, length: olds, parts: [] }
|
|
132
|
+
while (olds > 0 || news > 0) {
|
|
133
|
+
// A line its count reads past those read isn't one.
|
|
134
|
+
if (at >= end) throw unreadable()
|
|
135
|
+
const line = lines[at++]!
|
|
136
|
+
const kind = line === '' || line === '\r' || line[0] === ' ' ? 'context' : line[0] === '-' ? 'deletion' : line[0] === '+' ? 'insertion' : null
|
|
137
|
+
if (!kind) throw unreadable()
|
|
138
|
+
const last = hunk.parts.at(-1)
|
|
139
|
+
const part = last?.kind === kind ? last : hunk.parts[hunk.parts.push({ kind, lines: [] }) - 1]!
|
|
140
|
+
part.lines.push(line.slice(1))
|
|
141
|
+
if (kind !== 'insertion') olds--
|
|
142
|
+
if (kind !== 'deletion') news--
|
|
143
|
+
if (olds < 0 || news < 0) throw unreadable()
|
|
144
|
+
// The line before it the last of its side, with no LF after it.
|
|
145
|
+
if (lines[at]?.startsWith('\\')) {
|
|
146
|
+
if (!lines[at++]!.startsWith('\')) throw unreadable()
|
|
147
|
+
part.unended = true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { hunk, next: at }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A patch's files as git's diff writes them, as pnpm writes a patch: a file's
|
|
154
|
+
// `diff --git` line, its headers, the paths its sides have, and its hunks;
|
|
155
|
+
// what comes before the first file, or between one file and the next, left
|
|
156
|
+
// out. One whose own lines end in CRLFs, as an editor or git's autocrlf
|
|
157
|
+
// leaves one, is read as patch-package reads it: its headers but their CRs,
|
|
158
|
+
// its hunks' lines with them. Throws for one it can't read, or of bytes.
|
|
159
|
+
export function parsePatch(patch: string): PatchedFile[] {
|
|
160
|
+
const lines = patch.split('\n')
|
|
161
|
+
// The lines read: those but the empty one past the patch's last ending.
|
|
162
|
+
const end = lines.length - (patch.endsWith('\n') ? 1 : 0)
|
|
163
|
+
// A header's line but its CR.
|
|
164
|
+
const headerAt = (at: number) => lines[at]?.replace(/\r$/u, '') ?? ''
|
|
165
|
+
const files: PatchedFile[] = []
|
|
166
|
+
for (let at = 0; at < lines.length;) {
|
|
167
|
+
const head = headerAt(at++)
|
|
168
|
+
if (!head.startsWith('diff --git ')) continue
|
|
169
|
+
let created = false, deleted = false, from: string | null = null, to: string | null = null
|
|
170
|
+
let sides: [string | null, string | null] | null = null
|
|
171
|
+
for (; at < lines.length && !/^(?:diff --git |@@ |--- )/u.test(lines[at]!); at++) {
|
|
172
|
+
const header = headerAt(at)
|
|
173
|
+
if (header === 'GIT binary patch' || header.startsWith('Binary files ')) throw new NpmPackageError(422, 'patch-unsupported')
|
|
174
|
+
if (header.startsWith('copy from ') || header.startsWith('copy to ')) throw new NpmPackageError(422, 'patch-unsupported')
|
|
175
|
+
if (header.startsWith('deleted file mode ')) deleted = true
|
|
176
|
+
else if (header.startsWith('new file mode ')) created = true
|
|
177
|
+
else if (header.startsWith('rename from ')) from = unquoted(header.slice('rename from '.length))
|
|
178
|
+
else if (header.startsWith('rename to ')) to = unquoted(header.slice('rename to '.length))
|
|
179
|
+
}
|
|
180
|
+
if (lines[at]?.startsWith('--- ')) {
|
|
181
|
+
if (!lines[at + 1]?.startsWith('+++ ')) throw unreadable()
|
|
182
|
+
sides = [sidePath(headerAt(at).slice(4), 'a/'), sidePath(headerAt(at + 1).slice(4), 'b/')]
|
|
183
|
+
at += 2
|
|
184
|
+
}
|
|
185
|
+
const named = gitPath(head.slice('diff --git '.length))
|
|
186
|
+
const after = to ?? (sides ? sides[1] : named), before = from ?? (sides ? sides[0] : named)
|
|
187
|
+
if ((from === null) !== (to === null) || (deleted && created)) throw unreadable()
|
|
188
|
+
const path = deleted ? before : after
|
|
189
|
+
if (path == null || (!created && before == null) || (!deleted && after == null) || ![before, after].every(side => side == null || isPatchPath(side))
|
|
190
|
+
|| (from === null && !deleted && !created && before !== after)) throw unreadable()
|
|
191
|
+
const hunks: PatchHunk[] = []
|
|
192
|
+
while (lines[at]?.startsWith('@@ ')) {
|
|
193
|
+
const { hunk, next } = readHunk(lines, at, end)
|
|
194
|
+
hunks.push(hunk)
|
|
195
|
+
at = next
|
|
196
|
+
}
|
|
197
|
+
files.push({ path, ...from !== null && to !== null ? { from: before! } : {}, ...deleted ? { deleted: true as const } : {},
|
|
198
|
+
...created ? { created: true as const } : {}, hunks: deleted ? [] : hunks })
|
|
199
|
+
}
|
|
200
|
+
return files
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const doesNotApply = () => new NpmPackageError(422, 'patch-does-not-apply')
|
|
204
|
+
|
|
205
|
+
// A change a hunk makes to a file's lines (`lines`, split at its LFs): those
|
|
206
|
+
// at `splice` that it removes (`remove`) and adds in their place (`insert`),
|
|
207
|
+
// or an empty last line it adds or removes, its LF after the last.
|
|
208
|
+
type LineChange = { splice: number; remove: number; insert: string[] } | 'push' | 'pop'
|
|
209
|
+
|
|
210
|
+
// The changes `hunk` makes where its lines are at `at` of `lines`, as
|
|
211
|
+
// patch-package works them out (evaluateHunk): those it finds the same but
|
|
212
|
+
// for the space they end in, as it compares them; else null.
|
|
213
|
+
function changesAt(hunk: PatchHunk, lines: string[], at: number): LineChange[] | null {
|
|
214
|
+
if (at < 0 || lines.length - at < hunk.length) return null
|
|
215
|
+
const changes: LineChange[] = []
|
|
216
|
+
for (const part of hunk.parts) {
|
|
217
|
+
if (part.kind === 'insertion') {
|
|
218
|
+
changes.push({ splice: at, remove: 0, insert: part.lines })
|
|
219
|
+
if (part.unended) changes.push('pop')
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
for (const line of part.lines) if (lines[at++]!.trimEnd() !== line.trimEnd()) return null
|
|
223
|
+
if (part.kind === 'deletion') {
|
|
224
|
+
changes.push({ splice: at - part.lines.length, remove: part.lines.length, insert: [] })
|
|
225
|
+
if (part.unended) changes.push('push')
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return changes
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `text` with `hunks` applied as patch-package applies them (applyPatch):
|
|
232
|
+
// each one's lines found in the text as it was, where its header says, else
|
|
233
|
+
// as near as FUZZ lines either side, the nearer first, and the earlier of
|
|
234
|
+
// two as near; then its changes made in turn, past the lines those before
|
|
235
|
+
// added or removed.
|
|
236
|
+
function applied(text: string, hunks: PatchHunk[]): string {
|
|
237
|
+
const lines = text.split('\n')
|
|
238
|
+
const changes = hunks.flatMap(hunk => {
|
|
239
|
+
for (let offset = 0; Math.abs(offset) <= FUZZ; offset = offset < 0 ? -offset : -offset - 1) {
|
|
240
|
+
const found = changesAt(hunk, lines, hunk.at + offset)
|
|
241
|
+
if (found) return found
|
|
242
|
+
}
|
|
243
|
+
throw doesNotApply()
|
|
244
|
+
})
|
|
245
|
+
let shift = 0
|
|
246
|
+
for (const change of changes) {
|
|
247
|
+
if (change === 'push') lines.push('')
|
|
248
|
+
else if (change === 'pop') lines.pop()
|
|
249
|
+
else {
|
|
250
|
+
lines.splice(change.splice + shift, change.remove, ...change.insert)
|
|
251
|
+
shift += change.insert.length - change.remove
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return lines.join('\n')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A version's `files` with `patch` applied, in the order of their paths, as
|
|
258
|
+
// its tarball's are read; a file it changes the text of must be text. One it
|
|
259
|
+
// creates holds its first hunk's first lines, in place of any there, as
|
|
260
|
+
// patch-package writes it.
|
|
261
|
+
export function patchedFiles(files: NpmPackageFile[], patch: PatchedFile[]): NpmPackageFile[] {
|
|
262
|
+
const byPath = new Map(files.map(file => [file.path, file.bytes]))
|
|
263
|
+
const encoder = new TextEncoder()
|
|
264
|
+
for (const file of patch) {
|
|
265
|
+
if (file.created) {
|
|
266
|
+
const first = file.hunks[0]?.parts[0]
|
|
267
|
+
byPath.set(file.path, encoder.encode(first ? `${first.lines.join('\n')}${first.unended ? '' : '\n'}` : ''))
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const source = file.from ?? file.path
|
|
271
|
+
const bytes = byPath.get(source)
|
|
272
|
+
if (!bytes) throw doesNotApply()
|
|
273
|
+
if (file.deleted) { byPath.delete(source); continue }
|
|
274
|
+
let next = bytes
|
|
275
|
+
if (file.hunks.length > 0) {
|
|
276
|
+
const text = npmFileText(bytes)
|
|
277
|
+
if (text === null) throw doesNotApply()
|
|
278
|
+
next = encoder.encode(applied(text, file.hunks))
|
|
279
|
+
}
|
|
280
|
+
if (file.from !== undefined) {
|
|
281
|
+
if (byPath.has(file.path)) throw doesNotApply()
|
|
282
|
+
byPath.delete(source)
|
|
283
|
+
}
|
|
284
|
+
byPath.set(file.path, next)
|
|
285
|
+
}
|
|
286
|
+
return [...byPath].map(([path, bytes]) => ({ path, bytes })).toSorted((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// About the bytes a patch read takes: the characters of its paths and of the
|
|
290
|
+
// lines its hunks hold, and the room past them each line takes (LINE_COST)
|
|
291
|
+
// and each file, hunk and part (HUNK_COST), as V8 takes it; so a patch of a
|
|
292
|
+
// few long lines (a minified file's) weighs about its size, and one of many
|
|
293
|
+
// short lines or tiny hunks many times it, as it takes.
|
|
294
|
+
const HUNK_COST = 256, LINE_COST = 40
|
|
295
|
+
const held = (lines: string[]) => lines.reduce((sum, line) => sum + LINE_COST + line.length, HUNK_COST)
|
|
296
|
+
export const patchWeight = (files: PatchedFile[]) => files.reduce((sum, file) => sum + HUNK_COST + file.path.length + (file.from?.length ?? 0)
|
|
297
|
+
+ file.hunks.reduce((bytes, hunk) => bytes + HUNK_COST + hunk.parts.reduce((parts, part) => parts + held(part.lines), 0), 0), 1)
|
|
298
|
+
|
|
299
|
+
// Patches read lately, by their source and hash, as what a commit holds never
|
|
300
|
+
// changes: as many as MAX_KEPT_PATCHES of the bytes they take (patchWeight).
|
|
301
|
+
const MAX_KEPT_PATCHES = 64 * 1024 * 1024
|
|
302
|
+
const patches: Lru<PatchedFile[]> = lru(MAX_KEPT_PATCHES, patchWeight)
|
|
303
|
+
|
|
304
|
+
// The patch `patch` names, read as kept, else of repository `fullName` with
|
|
305
|
+
// `token`, held to its hash and read (parsePatch), and kept; `asked` told
|
|
306
|
+
// where GitHub was. Its file missing there, or other than its hash says, or
|
|
307
|
+
// past MAX_PATCH_BYTES, fails.
|
|
308
|
+
export async function readPatch(fullName: string, token: string, patch: NpmPatch, asked?: GithubAsked): Promise<PatchedFile[]> {
|
|
309
|
+
const key = `${patch.repoId}\0${patch.commit}\0${patch.path}\0${patch.hash}`
|
|
310
|
+
const known = patches.get(key)
|
|
311
|
+
if (known) return known
|
|
312
|
+
if (asked) asked.github = true
|
|
313
|
+
let text: string
|
|
314
|
+
try {
|
|
315
|
+
text = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).getRepoFile({ repo: fullName, path: patch.path, ref: patch.commit }))
|
|
316
|
+
} catch (err) {
|
|
317
|
+
if (err instanceof GithubApiError && err.message === 'github-not-found') throw new NpmPackageError(404, 'patch-not-found')
|
|
318
|
+
throw err
|
|
319
|
+
}
|
|
320
|
+
if (Buffer.byteLength(text) > MAX_PATCH_BYTES) throw new NpmPackageError(413, 'patch-too-large')
|
|
321
|
+
if (!isPatchOf(text, patch.hash)) throw new NpmPackageError(409, 'patch-mismatch')
|
|
322
|
+
const files = parsePatch(text)
|
|
323
|
+
patches.set(key, files)
|
|
324
|
+
return files
|
|
325
|
+
}
|
|
@@ -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}`)
|