Files
pnpm/deny.toml
Zoltan Kochan 3d50680eda fix(security): verify Node.js runtime SHASUMS OpenPGP signature (#12295)
Follow-up to #12292 (which verifies the **package-manager** binary). This closes the same class of gap for the **Node.js runtime**.

When a repository requests a Node.js runtime — `devEngines.runtime: node@X` (with `onFail: download`, the default) or `useNodeVersion` — pnpm downloads and then executes a Node binary (it's used to run lifecycle / `run` / `exec` scripts). The download **mirror is repository-configurable** via `node-mirror:<channel>` (`nodeDownloadMirrors`) in project `.npmrc`, and the integrity comes from `SHASUMS256.txt` fetched **from that same mirror**.

That's a circular check: a malicious mirror serves a tampered `node` tarball **and** a matching `SHASUMS256.txt`, the sha256 check passes, and pnpm runs the binary. Drive-by on a normal command in a cloned repo.

## Fix

pnpm now fetches `SHASUMS256.txt.sig` and verifies its **detached OpenPGP signature** against the **Node.js release team's public keys, embedded in the pnpm CLI**, before trusting the hashes. A mirror that serves a tampered binary cannot also produce a valid signature, so verification fails. Any faithful mirror (one that proxies the real signed SHASUMS) keeps working.

- `@pnpm/crypto.shasums-file`: new `fetchVerifiedNodeShasums` / `fetchVerifiedNodeShasumsFile` verify the signature via `openpgp` against the embedded keys.
- The keys live in a generated file (`src/nodeReleaseKeys.ts`, 28 keys) mirrored from the canonical `nodejs/release-keys` list. `crypto/shasums-file/scripts/update-node-release-keys.mjs` keeps them current (`pnpm check:node-release-keys` / `--update`), and the **create-release-pr** workflow runs the check as a gate so a new release signer can't silently break verification.
- `@pnpm/engine.runtime.node-resolver` verifies the **configurable-mirror** SHASUMS. The hardcoded `unofficial-builds.nodejs.org` musl mirror is **not** repo-configurable and is signed by a different key, so it stays trusted over TLS.

## Scope

- **Pre-release channels (rc, nightly, …) are not verified** — Node only signs the `release` channel (no `SHASUMS256.txt.sig` exists for them, even on nodejs.org), so they remain unverifiable. Verification is gated on the `release` channel.
- **Bun / Deno are unaffected** — their download/SHASUMS URLs are hardcoded to canonical GitHub (`github.com/oven-sh/bun`, `api.github.com/repos/denoland/deno`), not mirror-configurable, so a repo can't redirect them.
- **Pacquet parity:** `pacquet/crates/engine-runtime-node-resolver` has the same mirror-configurable SHASUMS logic and needs the equivalent Rust port — tracked as a follow-up (per the repo's parity rule, opening the TS side first).
2026-06-10 00:33:31 +02:00

130 lines
5.4 KiB
TOML

# Configuration for cargo-deny (https://embarkstudios.github.io/cargo-deny/).
# The schema evolves fast; fields follow the 0.19+ format.
# --- Graph ---------------------------------------------------------------
[graph]
targets = []
all-features = false
no-default-features = false
# --- Output --------------------------------------------------------------
[output]
feature-depth = 1
# --- Advisories ----------------------------------------------------------
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
# Scope for RUSTSEC unmaintained advisories.
# One of "all", "workspace", "transitive", "none".
unmaintained = "workspace"
# yanked-crates check: "deny" | "warn" | "allow"
yanked = "warn"
ignore = [
# `pgp` pulls in `rsa` to verify OpenPGP signatures made by RSA Node.js
# release keys. Pacquet only performs public-key signature verification here;
# it never handles RSA private keys, so the private-key timing side channel
# described by this advisory is not reachable through this use.
{ id = "RUSTSEC-2023-0071", reason = "Only public-key OpenPGP signature verification is performed for pinned Node.js release keys; no RSA private-key operation is exposed." },
# hickory-proto 0.25.2 is pulled in transitively through reqwest 0.13.x ->
# hickory-resolver 0.25.x. The reqwest 0.13 line has not migrated to
# hickory-proto 0.26, so `cargo update` cannot resolve either advisory; the
# only paths forward are an upstream reqwest release on hickory 0.26 or
# dropping the `hickory-dns` reqwest feature, which would regress the macOS
# `mDNSResponder` / `EAI_NONAME` workaround landed in #302. Revisit when
# reqwest moves to hickory 0.26.
#
# NSEC3 closest-encloser proof unbounded loop in `DnssecDnsHandle`. The
# vulnerable path is only linked when hickory-proto is built with the
# `dnssec-ring` or `dnssec-aws-lc-rs` Cargo feature; reqwest's `hickory-dns`
# feature does not enable either, so the affected code is unreachable in
# pacquet. The advisory itself notes "No safe upgrade is available" for the
# 0.25 line.
{ id = "RUSTSEC-2026-0118", reason = "DNSSEC validation path is not linked: reqwest's `hickory-dns` feature does not enable hickory-proto's `dnssec-ring`/`dnssec-aws-lc-rs` features, and no fix exists on the 0.25 line." },
# O(n²) name compression in `BinEncoder` during DNS message encoding.
# Reachability is bounded: the BinEncoder is only invoked when reqwest's
# `hickory-dns` resolver builds outbound DNS queries for the registry
# hostnames pacquet resolves, which originate from `.npmrc` and so can be
# attacker-influenced in an untrusted-checkout / untrusted-CI scenario. We
# accept this temporary DoS risk because no upgrade is reachable: reqwest
# 0.13.x (latest 0.13.3) is locked to `hickory-resolver` 0.25, and the fix
# ships only in `hickory-proto` 0.26.1+. Revisit when reqwest moves to
# hickory 0.26.
{ id = "RUSTSEC-2026-0119", reason = "Temporary risk acceptance: reqwest 0.13.x (latest 0.13.3) is locked to hickory-resolver 0.25 and no release consumes hickory-proto 0.26.1+ yet; revisit on reqwest upgrade." },
]
# --- Licenses ------------------------------------------------------------
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
allow = [
"MIT",
"MPL-2.0", # required by mockito, used by crates/tarball tests and tasks/micro-benchmark
"Apache-2.0",
"Unicode-3.0", # newer ICU crates switched from Unicode-DFS-2016 to this
"Unicode-DFS-2016",
"BSD-3-Clause",
"BSL-1.0",
"CDLA-Permissive-2.0", # `webpki-root-certs`, pulled in by reqwest's `rustls` feature
"ISC",
"Zlib", # required by foldhash, a transitive dep of rusqlite
]
confidence-threshold = 0.8
exceptions = [
# `pnpr` and `pnpr-fixtures` are first-party crates licensed under
# PolyForm Shield (see `pnpr/LICENSE.md`), not the MIT used by the rest
# of the workspace. cargo-deny only recognizes the PolyForm-Noncommercial
# identifier for this license text, so allow that id for just these two
# crates (the clarify entries below pin it by file hash).
{ name = "pnpr", allow = ["PolyForm-Noncommercial-1.0.0"] },
{ name = "pnpr-fixtures", allow = ["PolyForm-Noncommercial-1.0.0"] },
]
[[licenses.clarify]]
name = "pnpr"
expression = "PolyForm-Noncommercial-1.0.0"
license-files = [{ path = "../../LICENSE.md", hash = 0x652a978e }]
[[licenses.clarify]]
name = "pnpr-fixtures"
expression = "PolyForm-Noncommercial-1.0.0"
license-files = [{ path = "../../LICENSE.md", hash = 0x652a978e }]
[[licenses.clarify]]
name = "ring"
version = "*"
expression = "MIT AND ISC AND OpenSSL"
license-files = [
{ path = "LICENSE", hash = 0xbd0eed23 },
]
[licenses.private]
ignore = false
registries = []
# --- Bans ----------------------------------------------------------------
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
multiple-versions = "warn"
wildcards = "allow"
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
allow = []
deny = []
skip = []
skip-tree = []
# --- Sources -------------------------------------------------------------
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
[sources.allow-org]
github = []
gitlab = []
bitbucket = []