mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 17:35:30 -04:00
feat(pacquet-cli): port audit signatures (#12663)
Port pnpm's `pnpm audit signatures` registry-signature verification to pacquet. For every installed package, the package's own registry is queried for its signing keys (`/-/npm/v1/keys`) and full packument; the package is verified as soon as one of its `dist.signatures` validates over the message `name@version:integrity` against a trusted ECDSA-P256 key. Registries that advertise no signing keys are skipped (no trust root); a package whose registry provides keys but whose signature is absent is reported as missing, and one whose signature is present but does not validate as invalid (a tamper signal). Exit code 1 when any package is missing or invalid; `--json` emits the structured report. Mirrors pnpm's deps.security.signatures package and the auditSignatures command handler, including the per-package packument-error handling, the "one valid signature wins / prefer the tamper reason" logic, the key-expiry consistency check, encodeURIComponent-faithful packument URLs, the report wording, the error codes, and the JSON result shape. Adds the `p256` (RustCrypto) crate for ECDSA-P256/SHA-256 verification; it was already present transitively in the lockfile.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3584,6 +3584,7 @@ dependencies = [
|
||||
"mockito",
|
||||
"node-semver",
|
||||
"owo-colors",
|
||||
"p256",
|
||||
"pacquet-catalogs-config",
|
||||
"pacquet-cmd-shim",
|
||||
"pacquet-config",
|
||||
|
||||
@@ -130,6 +130,7 @@ reqwest = { version = "0.13", default-features = false, features = [
|
||||
"stream",
|
||||
] }
|
||||
node-semver = { version = "2.2.0" }
|
||||
p256 = { version = "0.13.2", default-features = false, features = ["ecdsa", "pkcs8", "std"] }
|
||||
pathdiff = { version = "0.2.3" }
|
||||
pipe-trait = { version = "0.4.0" }
|
||||
pgp = { version = "0.19.0", default-features = false }
|
||||
|
||||
@@ -58,6 +58,7 @@ indexmap = { workspace = true }
|
||||
node-semver = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
owo-colors.workspace = true
|
||||
p256 = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
pipe-trait = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
@@ -8,7 +8,7 @@ use owo_colors::{OwoColorize, Stream};
|
||||
use pacquet_config::{AuditLevel as ConfigAuditLevel, Config};
|
||||
use pacquet_lockfile::{
|
||||
EnvLockfile, ImporterDepVersion, Lockfile, PackageKey, PkgName, ResolvedDependencyMap,
|
||||
SnapshotDepRef, SnapshotEntry, SpecifierAndResolution,
|
||||
SnapshotDepRef, SnapshotEntry, SpecifierAndResolution, pick_registry_for_package,
|
||||
};
|
||||
use pacquet_network::{RetryOpts, send_with_retry};
|
||||
use pacquet_package_manager::{ResolutionObserver, ResolvedPackageHint, Update};
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod signatures;
|
||||
|
||||
const MAX_PATHS_COUNT: usize = 3;
|
||||
const MAX_PATHS_PER_FINDING: usize = 100;
|
||||
|
||||
@@ -68,7 +70,8 @@ pub struct AuditArgs {
|
||||
#[clap(short = 'i', long)]
|
||||
pub interactive: bool,
|
||||
|
||||
/// Audit subcommand. `audit signatures` has not been ported yet.
|
||||
/// Audit subcommand. The only supported subcommand is `signatures`,
|
||||
/// which verifies registry signatures for the installed packages.
|
||||
pub params: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -141,16 +144,22 @@ impl AuditArgs {
|
||||
mut state: State,
|
||||
) -> miette::Result<AuditOutcome> {
|
||||
if let Some(subcommand) = self.params.first() {
|
||||
return if subcommand == "signatures" {
|
||||
Err(miette::miette!(
|
||||
"`pacquet audit signatures` is not supported yet; registry signature verification has not been ported to pacquet."
|
||||
))
|
||||
} else {
|
||||
Err(AuditError::UnknownSubcommand {
|
||||
subcommand: self.params.iter().take(2).cloned().collect::<Vec<_>>().join(" "),
|
||||
if subcommand == "signatures" {
|
||||
if self.params.len() > 1 {
|
||||
return Err(AuditError::UnknownSubcommand {
|
||||
subcommand: self
|
||||
.params
|
||||
.iter()
|
||||
.take(2)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
.into())
|
||||
};
|
||||
return self.run_signatures(state).await;
|
||||
}
|
||||
return Err(AuditError::UnknownSubcommand { subcommand: subcommand.clone() }.into());
|
||||
}
|
||||
|
||||
let include = self.dependency_options.include();
|
||||
@@ -309,6 +318,69 @@ impl AuditArgs {
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `audit signatures`: verify registry signatures for every
|
||||
/// installed package and print the report. Exit code 1 (via
|
||||
/// [`AuditOutcome::Vulnerable`]) when any signature is missing or invalid.
|
||||
/// Ports pnpm's `auditSignatures`.
|
||||
async fn run_signatures(&self, state: State) -> miette::Result<AuditOutcome> {
|
||||
let include = self.dependency_options.include();
|
||||
let lockfile_dir = state
|
||||
.manifest
|
||||
.path()
|
||||
.parent()
|
||||
.map_or_else(|| state.manifest.path().to_path_buf(), std::path::Path::to_path_buf);
|
||||
|
||||
let packages = {
|
||||
let lockfile = state
|
||||
.lockfile
|
||||
.get()
|
||||
.map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?;
|
||||
let Some(lockfile) = lockfile else {
|
||||
return Err(AuditError::NoLockfile.into());
|
||||
};
|
||||
let env_lockfile_dir = state.config.workspace_dir.as_deref().unwrap_or(&lockfile_dir);
|
||||
let env_lockfile = EnvLockfile::read(env_lockfile_dir)
|
||||
.map_err(|err| miette::Report::new(err).wrap_err("load the env lockfile"))?;
|
||||
let audit_request = lockfile_to_audit_request(lockfile, env_lockfile.as_ref(), include);
|
||||
let registries: HashMap<String, String> =
|
||||
state.config.resolved_registries().into_iter().collect();
|
||||
audit_request
|
||||
.request
|
||||
.iter()
|
||||
.flat_map(|(name, versions)| {
|
||||
let registry = pick_registry_for_package(®istries, name, None);
|
||||
versions.iter().map(move |version| signatures::SignaturePackage {
|
||||
name: name.clone(),
|
||||
registry: registry.clone(),
|
||||
version: version.clone(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if packages.is_empty() {
|
||||
return Err(AuditError::NoPackages.into());
|
||||
}
|
||||
|
||||
let result =
|
||||
signatures::verify_signatures(&packages, state.config, state.http_client.as_ref())
|
||||
.await?;
|
||||
|
||||
let output = if self.json {
|
||||
serde_json::to_string_pretty(&result).into_diagnostic()?
|
||||
} else {
|
||||
signatures::render_signature_verification_result(&result)
|
||||
};
|
||||
print!("{output}");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
Ok(if result.invalid.is_empty() && result.missing.is_empty() {
|
||||
AuditOutcome::Clean
|
||||
} else {
|
||||
AuditOutcome::Vulnerable
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
@@ -318,6 +390,10 @@ enum AuditError {
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_NO_LOCKFILE))]
|
||||
NoLockfile,
|
||||
|
||||
#[display("No installed packages found to audit")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_NO_PACKAGES))]
|
||||
NoPackages,
|
||||
|
||||
#[display("No pnpm-lock.yaml found after update: Cannot report fixed vulnerabilities")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_NO_LOCKFILE))]
|
||||
NoLockfileAfterUpdate,
|
||||
|
||||
638
pacquet/crates/cli/src/cli_args/audit/signatures.rs
Normal file
638
pacquet/crates/cli/src/cli_args/audit/signatures.rs
Normal file
@@ -0,0 +1,638 @@
|
||||
//! `pacquet audit signatures` — verify ECDSA registry signatures for the
|
||||
//! installed packages.
|
||||
//!
|
||||
//! Ports pnpm's
|
||||
//! [`@pnpm/deps.security.signatures`](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/deps/security/signatures/src/verifySignatures.ts)
|
||||
//! and the
|
||||
//! [`audit signatures` command](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/deps/compliance/commands/src/audit/signatures.ts).
|
||||
//!
|
||||
//! For every installed `name@version`, the package's own registry is asked
|
||||
//! for its signing keys (`/-/npm/v1/keys`) and its full packument. A
|
||||
//! package is **verified** as soon as one of its `dist.signatures` validates,
|
||||
//! over the message `name@version:integrity`, against a trusted
|
||||
//! ECDSA-P256 key. Registries that advertise no signing keys are skipped
|
||||
//! (there is no trust root to check against); a package whose registry does
|
||||
//! provide keys but whose signature is absent is **missing**, and one whose
|
||||
//! signature is present but does not validate is **invalid** — a tamper
|
||||
//! signal.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
fmt::Write as _,
|
||||
};
|
||||
|
||||
use base64::Engine as _;
|
||||
use owo_colors::{OwoColorize, Stream};
|
||||
use p256::{
|
||||
ecdsa::{Signature, VerifyingKey, signature::Verifier},
|
||||
pkcs8::DecodePublicKey,
|
||||
};
|
||||
use pacquet_config::Config;
|
||||
use pacquet_network::{ThrottledClient, redact_url_credentials, send_with_retry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{bold, red, retry_opts_from_config, sanitize_response_body};
|
||||
|
||||
/// One installed package to check, already routed to the registry it was
|
||||
/// installed from.
|
||||
pub(super) struct SignaturePackage {
|
||||
pub name: String,
|
||||
pub registry: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// A package that failed (or lacks) signature verification. JSON-serialized
|
||||
/// in the `--json` report; `integrity`, `reason`, and `resolved` are omitted
|
||||
/// when absent, matching pnpm's `JSON.stringify` dropping `undefined`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SignatureIssue {
|
||||
pub name: String,
|
||||
pub registry: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub integrity: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(super) struct SignatureVerificationResult {
|
||||
pub audited: usize,
|
||||
pub invalid: Vec<SignatureIssue>,
|
||||
pub missing: Vec<SignatureIssue>,
|
||||
pub verified: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RegistryKey {
|
||||
#[serde(default)]
|
||||
expires: Option<String>,
|
||||
key: String,
|
||||
keyid: String,
|
||||
keytype: String,
|
||||
scheme: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryKeysResponse {
|
||||
keys: Vec<RegistryKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PackageSignature {
|
||||
keyid: String,
|
||||
sig: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Dist {
|
||||
#[serde(default)]
|
||||
integrity: Option<String>,
|
||||
#[serde(default)]
|
||||
tarball: Option<String>,
|
||||
#[serde(default)]
|
||||
signatures: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PackumentVersion {
|
||||
#[serde(default)]
|
||||
dist: Option<Dist>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Packument {
|
||||
/// Per-version publish times. Kept as raw JSON values (rather than
|
||||
/// `String`s) because the object also holds `created`/`modified` keys and
|
||||
/// pnpm never validates the shape — only `versions` is required.
|
||||
#[serde(default)]
|
||||
time: BTreeMap<String, serde_json::Value>,
|
||||
versions: HashMap<String, PackumentVersion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, miette::Diagnostic)]
|
||||
#[non_exhaustive]
|
||||
pub(super) enum SignaturesError {
|
||||
// `reason` is the registry error already passed through
|
||||
// `redact_url_credentials`; the raw `reqwest::Error` is not carried as a
|
||||
// diagnostic source, so embedded `user:pass@` credentials cannot leak via
|
||||
// its `Display` or the miette cause chain.
|
||||
#[display("Failed to request the registry keys endpoint (at {url}): {reason}")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))]
|
||||
KeysNetwork { url: String, reason: String },
|
||||
|
||||
#[display("The registry keys endpoint (at {url}) responded with {status}: {body}")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))]
|
||||
KeysBadStatus { url: String, status: u16, body: String },
|
||||
|
||||
#[display(
|
||||
"The registry keys endpoint (at {url}) returned invalid JSON: {reason}. Response body: {body}"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))]
|
||||
KeysInvalidJson { url: String, reason: String, body: String },
|
||||
|
||||
#[display(
|
||||
"The registry keys endpoint (at {url}) returned an unexpected body. Expected an object with a keys array; got: {body}"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL))]
|
||||
KeysUnexpectedBody { url: String, body: String },
|
||||
|
||||
/// See [`SignaturesError::KeysNetwork`] for why the error is stored as a
|
||||
/// pre-redacted string rather than a `reqwest::Error` source.
|
||||
#[display("Failed to request the packument endpoint (at {url}): {reason}")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))]
|
||||
PackumentNetwork { url: String, reason: String },
|
||||
|
||||
#[display("The packument endpoint (at {url}) responded with {status}: {body}")]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))]
|
||||
PackumentBadStatus { url: String, status: u16, body: String },
|
||||
|
||||
#[display(
|
||||
"The packument endpoint (at {url}) returned invalid JSON: {reason}. Response body: {body}"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))]
|
||||
PackumentInvalidJson { url: String, reason: String, body: String },
|
||||
|
||||
#[display(
|
||||
"The packument endpoint (at {url}) returned an unexpected body. Expected an object with versions; got: {body}"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_AUDIT_SIGNATURE_PACKUMENT_FETCH_FAIL))]
|
||||
PackumentUnexpectedBody { url: String, body: String },
|
||||
}
|
||||
|
||||
/// Verify registry signatures for every package in `packages`. Keys are
|
||||
/// fetched once per registry; packuments once per `(registry, name)` and
|
||||
/// reused across a package's installed versions. A keys-endpoint failure is
|
||||
/// fatal (no trust root); a packument failure is recorded against just the
|
||||
/// packages that needed it, mirroring pnpm's per-package `catch`.
|
||||
pub(super) async fn verify_signatures(
|
||||
packages: &[SignaturePackage],
|
||||
config: &Config,
|
||||
http_client: &ThrottledClient,
|
||||
) -> Result<SignatureVerificationResult, SignaturesError> {
|
||||
let registries: BTreeSet<&str> = packages.iter().map(|pkg| pkg.registry.as_str()).collect();
|
||||
let key_fetches = registries.into_iter().map(|registry| async move {
|
||||
fetch_registry_keys(registry, config, http_client)
|
||||
.await
|
||||
.map(|keys| (registry.to_string(), keys))
|
||||
});
|
||||
let keys_by_registry: HashMap<String, Vec<RegistryKey>> =
|
||||
futures_util::future::try_join_all(key_fetches).await?.into_iter().collect();
|
||||
|
||||
// Only fetch packuments for registries that advertise signing keys; a
|
||||
// registry without keys is skipped entirely.
|
||||
let needed: BTreeSet<(&str, &str)> = packages
|
||||
.iter()
|
||||
.filter(|pkg| keys_by_registry.get(&pkg.registry).is_some_and(|keys| !keys.is_empty()))
|
||||
.map(|pkg| (pkg.registry.as_str(), pkg.name.as_str()))
|
||||
.collect();
|
||||
let packument_fetches = needed.into_iter().map(|(registry, name)| async move {
|
||||
let result = fetch_packument(name, registry, config, http_client)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
((registry.to_string(), name.to_string()), result)
|
||||
});
|
||||
let packuments: HashMap<(String, String), Result<Option<Packument>, String>> =
|
||||
futures_util::future::join_all(packument_fetches).await.into_iter().collect();
|
||||
|
||||
let mut result = SignatureVerificationResult::default();
|
||||
for pkg in packages {
|
||||
let Some(keys) = keys_by_registry.get(&pkg.registry).filter(|keys| !keys.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
match packuments.get(&(pkg.registry.clone(), pkg.name.clone())) {
|
||||
Some(Err(reason)) => {
|
||||
result.invalid.push(issue(pkg, None, None, Some(reason.clone())));
|
||||
}
|
||||
Some(Ok(None)) | None => {}
|
||||
Some(Ok(Some(packument))) => {
|
||||
result.audited += 1;
|
||||
process_version(pkg, packument, keys, &mut result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.invalid.sort_by_key(sort_key);
|
||||
result.missing.sort_by_key(sort_key);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn process_version(
|
||||
pkg: &SignaturePackage,
|
||||
packument: &Packument,
|
||||
keys: &[RegistryKey],
|
||||
result: &mut SignatureVerificationResult,
|
||||
) {
|
||||
let version = packument.versions.get(&pkg.version);
|
||||
let published_at =
|
||||
packument.time.get(&pkg.version).and_then(serde_json::Value::as_str).map(str::to_string);
|
||||
let dist = version.and_then(|version| version.dist.as_ref());
|
||||
let integrity = dist.and_then(|dist| dist.integrity.clone());
|
||||
let resolved = dist.and_then(|dist| dist.tarball.clone());
|
||||
let raw_signatures = dist.and_then(|dist| dist.signatures.as_ref());
|
||||
|
||||
if raw_signatures.is_some_and(|value| !value.is_array()) {
|
||||
result.invalid.push(issue(pkg, integrity, resolved, Some(malformed_reason(pkg))));
|
||||
return;
|
||||
}
|
||||
let mut signatures = Vec::new();
|
||||
if let Some(serde_json::Value::Array(elements)) = raw_signatures {
|
||||
for element in elements {
|
||||
let Ok(signature) = serde_json::from_value::<PackageSignature>(element.clone()) else {
|
||||
result.invalid.push(issue(pkg, integrity, resolved, Some(malformed_reason(pkg))));
|
||||
return;
|
||||
};
|
||||
signatures.push(signature);
|
||||
}
|
||||
}
|
||||
|
||||
if version.is_none() {
|
||||
let reason = format!("Missing registry metadata for {}@{}", pkg.name, pkg.version);
|
||||
result.invalid.push(issue(pkg, None, None, Some(reason)));
|
||||
return;
|
||||
}
|
||||
let Some(integrity) = integrity else {
|
||||
result.missing.push(issue(pkg, None, resolved, None));
|
||||
return;
|
||||
};
|
||||
if signatures.is_empty() {
|
||||
result.missing.push(issue(pkg, Some(integrity), resolved, None));
|
||||
return;
|
||||
}
|
||||
|
||||
match verify_package_signatures(
|
||||
pkg,
|
||||
&integrity,
|
||||
published_at.as_deref(),
|
||||
resolved.as_deref(),
|
||||
&signatures,
|
||||
keys,
|
||||
) {
|
||||
Some(invalid) => result.invalid.push(invalid),
|
||||
None => result.verified += 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` as soon as one signature validates against a trusted key.
|
||||
/// Unknown-key, expired-key, and invalid-signature outcomes are recorded but
|
||||
/// do not on their own fail the package — only the absence of any valid
|
||||
/// signature does. This keeps a key rotation (multiple signatures in the
|
||||
/// packument) working and stops a mirror from forcing a failure by appending
|
||||
/// junk. The surfaced reason prefers an invalid-signature failure (a tamper
|
||||
/// signal) over the weaker unknown/expired reasons.
|
||||
fn verify_package_signatures(
|
||||
pkg: &SignaturePackage,
|
||||
integrity: &str,
|
||||
published_at: Option<&str>,
|
||||
resolved: Option<&str>,
|
||||
signatures: &[PackageSignature],
|
||||
keys: &[RegistryKey],
|
||||
) -> Option<SignatureIssue> {
|
||||
let message = format!("{}@{}:{integrity}", pkg.name, pkg.version);
|
||||
let published_time = published_at.and_then(parse_timestamp);
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for signature in signatures {
|
||||
let Some(key) = keys.iter().find(|key| key.keyid == signature.keyid) else {
|
||||
failures.push(format!(
|
||||
"{}@{} has a registry signature with keyid {} but no corresponding public key can be found",
|
||||
pkg.name, pkg.version, signature.keyid,
|
||||
));
|
||||
continue;
|
||||
};
|
||||
// Key expiry is a consistency check, not a security boundary: the
|
||||
// publish time comes from the same unauthenticated packument as the
|
||||
// signatures. A missing or unparsable publish time therefore keeps the
|
||||
// key usable — the signature check below is what gates acceptance.
|
||||
let expired = match (key.expires.as_deref().and_then(parse_timestamp), published_time) {
|
||||
(Some(expires), Some(published)) => published >= expires,
|
||||
_ => false,
|
||||
};
|
||||
if expired {
|
||||
failures.push(format!(
|
||||
"{}@{} has a registry signature with keyid {} but the corresponding public key has expired {}",
|
||||
pkg.name,
|
||||
pkg.version,
|
||||
signature.keyid,
|
||||
key.expires.as_deref().unwrap_or_default(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if verify_one(&key.key, &message, &signature.sig) {
|
||||
return None;
|
||||
}
|
||||
failures.push(format!(
|
||||
"{}@{} has an invalid registry signature with keyid {}",
|
||||
pkg.name, pkg.version, signature.keyid,
|
||||
));
|
||||
}
|
||||
|
||||
Some(issue(
|
||||
pkg,
|
||||
Some(integrity.to_string()),
|
||||
resolved.map(str::to_string),
|
||||
Some(most_telling_failure(pkg, &failures)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Verify one base64 ECDSA-P256 signature over `message` against a base64
|
||||
/// SPKI public key. Any malformed key material or signature bytes count as a
|
||||
/// non-match rather than an error, so one bad key can't abort the audit.
|
||||
fn verify_one(public_key_base64: &str, message: &str, signature_base64: &str) -> bool {
|
||||
let engine = base64::engine::general_purpose::STANDARD;
|
||||
let Ok(key_der) = engine.decode(public_key_base64) else { return false };
|
||||
let Ok(verifying_key) = VerifyingKey::from_public_key_der(&key_der) else { return false };
|
||||
let Ok(signature_der) = engine.decode(signature_base64) else { return false };
|
||||
let Ok(signature) = Signature::from_der(&signature_der) else { return false };
|
||||
verifying_key.verify(message.as_bytes(), &signature).is_ok()
|
||||
}
|
||||
|
||||
fn most_telling_failure(pkg: &SignaturePackage, failures: &[String]) -> String {
|
||||
if failures.is_empty() {
|
||||
return format!(
|
||||
"{}@{} has no registry signature from a trusted key",
|
||||
pkg.name, pkg.version,
|
||||
);
|
||||
}
|
||||
failures
|
||||
.iter()
|
||||
.find(|reason| reason.contains("invalid registry signature"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| failures[0].clone())
|
||||
}
|
||||
|
||||
fn issue(
|
||||
pkg: &SignaturePackage,
|
||||
integrity: Option<String>,
|
||||
resolved: Option<String>,
|
||||
reason: Option<String>,
|
||||
) -> SignatureIssue {
|
||||
SignatureIssue {
|
||||
name: pkg.name.clone(),
|
||||
registry: pkg.registry.clone(),
|
||||
version: pkg.version.clone(),
|
||||
integrity,
|
||||
reason,
|
||||
resolved,
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed_reason(pkg: &SignaturePackage) -> String {
|
||||
format!("Malformed registry signatures metadata for {}@{}", pkg.name, pkg.version)
|
||||
}
|
||||
|
||||
fn sort_key(issue: &SignatureIssue) -> String {
|
||||
format!("{}@{}", issue.name, issue.version)
|
||||
}
|
||||
|
||||
/// Parse an ISO-8601 / RFC-3339 timestamp to epoch milliseconds, returning
|
||||
/// `None` when it can't be parsed (mirroring JS `Date.parse` yielding `NaN`,
|
||||
/// which then compares false).
|
||||
fn parse_timestamp(value: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(value).ok().map(|datetime| datetime.timestamp_millis())
|
||||
}
|
||||
|
||||
async fn fetch_registry_keys(
|
||||
registry: &str,
|
||||
config: &Config,
|
||||
http_client: &ThrottledClient,
|
||||
) -> Result<Vec<RegistryKey>, SignaturesError> {
|
||||
let registry_url = with_trailing_slash(registry);
|
||||
let keys_url = format!("{registry_url}-/npm/v1/keys");
|
||||
let display_url = redact_url_credentials(&keys_url);
|
||||
let authorization = config.auth_headers.for_url(®istry_url);
|
||||
// Keep the throttle guard alive until the body is fully read; dropping it
|
||||
// before `response.text()` would release the concurrency permit while the
|
||||
// socket is still draining (see [`send_with_retry`]).
|
||||
let (_guard, response) =
|
||||
send_with_retry(http_client, &keys_url, retry_opts_from_config(config), |client| {
|
||||
let mut request = client.get(&keys_url).header("accept", "application/json");
|
||||
if let Some(value) = &authorization {
|
||||
request = request.header("authorization", value);
|
||||
}
|
||||
request
|
||||
})
|
||||
.await
|
||||
.map_err(|source| SignaturesError::KeysNetwork {
|
||||
url: display_url.clone(),
|
||||
reason: redact_url_credentials(&source.to_string()),
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.map_err(|source| SignaturesError::KeysNetwork {
|
||||
url: display_url.clone(),
|
||||
reason: redact_url_credentials(&source.to_string()),
|
||||
})?;
|
||||
// npm registries answer 404 (no signing) and 400 the same way: there is no
|
||||
// trust root, so the registry's packages are simply not audited.
|
||||
if status == 404 || status == 400 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(SignaturesError::KeysBadStatus {
|
||||
url: display_url,
|
||||
status,
|
||||
body: sanitize_response_body(&body),
|
||||
});
|
||||
}
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body).map_err(|err| SignaturesError::KeysInvalidJson {
|
||||
url: display_url.clone(),
|
||||
reason: err.to_string(),
|
||||
body: sanitize_response_body(&body),
|
||||
})?;
|
||||
let parsed: RegistryKeysResponse =
|
||||
serde_json::from_value(value.clone()).map_err(|_| SignaturesError::KeysUnexpectedBody {
|
||||
url: display_url,
|
||||
body: sanitize_response_body(&value.to_string()),
|
||||
})?;
|
||||
|
||||
// npm registry signing uses ECDSA P-256 keys; provenance attestations are
|
||||
// handled separately and intentionally ignored here.
|
||||
Ok(parsed
|
||||
.keys
|
||||
.into_iter()
|
||||
.filter(|key| key.keytype == "ecdsa-sha2-nistp256" && key.scheme == "ecdsa-sha2-nistp256")
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn fetch_packument(
|
||||
name: &str,
|
||||
registry: &str,
|
||||
config: &Config,
|
||||
http_client: &ThrottledClient,
|
||||
) -> Result<Option<Packument>, SignaturesError> {
|
||||
let registry_url = with_trailing_slash(registry);
|
||||
let packument_url = format!("{registry_url}{}", encode_package_name(name));
|
||||
let display_url = redact_url_credentials(&packument_url);
|
||||
let authorization = config.auth_headers.for_url(®istry_url);
|
||||
// Hold the throttle guard until the body is read; see `fetch_registry_keys`.
|
||||
let (_guard, response) =
|
||||
send_with_retry(http_client, &packument_url, retry_opts_from_config(config), |client| {
|
||||
let mut request = client.get(&packument_url).header("accept", "application/json");
|
||||
if let Some(value) = &authorization {
|
||||
request = request.header("authorization", value);
|
||||
}
|
||||
request
|
||||
})
|
||||
.await
|
||||
.map_err(|source| SignaturesError::PackumentNetwork {
|
||||
url: display_url.clone(),
|
||||
reason: redact_url_credentials(&source.to_string()),
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.map_err(|source| SignaturesError::PackumentNetwork {
|
||||
url: display_url.clone(),
|
||||
reason: redact_url_credentials(&source.to_string()),
|
||||
})?;
|
||||
if status == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(SignaturesError::PackumentBadStatus {
|
||||
url: display_url,
|
||||
status,
|
||||
body: sanitize_response_body(&body),
|
||||
});
|
||||
}
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&body).map_err(|err| SignaturesError::PackumentInvalidJson {
|
||||
url: display_url.clone(),
|
||||
reason: err.to_string(),
|
||||
body: sanitize_response_body(&body),
|
||||
})?;
|
||||
let parsed: Packument = serde_json::from_value(value.clone()).map_err(|_| {
|
||||
SignaturesError::PackumentUnexpectedBody {
|
||||
url: display_url,
|
||||
body: sanitize_response_body(&value.to_string()),
|
||||
}
|
||||
})?;
|
||||
Ok(Some(parsed))
|
||||
}
|
||||
|
||||
fn with_trailing_slash(registry: &str) -> String {
|
||||
if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") }
|
||||
}
|
||||
|
||||
/// Percent-encode a package name for a packument URL, matching pnpm's
|
||||
/// `toUri`: a scoped name keeps its leading `@` and encodes the rest (so the
|
||||
/// `/` becomes `%2F`), an unscoped name is encoded whole.
|
||||
fn encode_package_name(name: &str) -> String {
|
||||
match name.strip_prefix('@') {
|
||||
Some(rest) => format!("@{}", encode_uri_component(rest)),
|
||||
None => encode_uri_component(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Port of JavaScript `encodeURIComponent`: every UTF-8 byte outside the
|
||||
/// unreserved set is percent-encoded.
|
||||
fn encode_uri_component(input: &str) -> String {
|
||||
const UNRESERVED: &[u8] = b"-_.!~*'()";
|
||||
let mut output = String::with_capacity(input.len());
|
||||
for &byte in input.as_bytes() {
|
||||
if byte.is_ascii_alphanumeric() || UNRESERVED.contains(&byte) {
|
||||
output.push(byte as char);
|
||||
} else {
|
||||
write!(output, "%{byte:02X}").expect("writing to a String never fails");
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn render_signature_verification_result(result: &SignatureVerificationResult) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("audited {} {}", result.audited, plural(result.audited, "package")));
|
||||
lines.push(String::new());
|
||||
|
||||
if result.verified > 0 {
|
||||
lines.push(format!(
|
||||
"{} {} {} registry {}",
|
||||
result.verified,
|
||||
if result.verified == 1 { "package has a" } else { "packages have" },
|
||||
bold("verified"),
|
||||
plural(result.verified, "signature"),
|
||||
));
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !result.missing.is_empty() {
|
||||
let count = result.missing.len();
|
||||
lines.push(format!(
|
||||
"{count} {} {} registry {} but the registry is providing signing keys:",
|
||||
if count == 1 { "package is" } else { "packages are" },
|
||||
bright_red("missing"),
|
||||
plural(count, "signature"),
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push(issue_table(&result.missing, false));
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !result.invalid.is_empty() {
|
||||
let count = result.invalid.len();
|
||||
lines.push(format!(
|
||||
"{count} {} {} registry {}:",
|
||||
if count == 1 { "package has an" } else { "packages have" },
|
||||
bright_red("invalid"),
|
||||
plural(count, "signature"),
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push(issue_table(&result.invalid, true));
|
||||
lines.push(String::new());
|
||||
lines.push(
|
||||
if count == 1 {
|
||||
"Someone might have tampered with this package since it was published on the registry!"
|
||||
} else {
|
||||
"Someone might have tampered with these packages since they were published on the registry!"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if result.audited == 0
|
||||
&& result.invalid.is_empty()
|
||||
&& result.missing.is_empty()
|
||||
&& result.verified == 0
|
||||
{
|
||||
lines.push("No dependencies were installed from a registry with signing keys".to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn issue_table(issues: &[SignatureIssue], with_reason: bool) -> String {
|
||||
use tabled::{builder::Builder, settings::Style};
|
||||
|
||||
let mut builder = Builder::default();
|
||||
for issue in issues {
|
||||
let package = red(&format!("{}@{}", issue.name, issue.version));
|
||||
if with_reason {
|
||||
let reason =
|
||||
issue.reason.clone().unwrap_or_else(|| "Invalid registry signature".to_string());
|
||||
builder.push_record(vec![package, issue.registry.clone(), reason]);
|
||||
} else {
|
||||
builder.push_record(vec![package, issue.registry.clone()]);
|
||||
}
|
||||
}
|
||||
let mut table = builder.build();
|
||||
table.with(Style::modern());
|
||||
table.to_string()
|
||||
}
|
||||
|
||||
fn plural(count: usize, word: &str) -> String {
|
||||
if count == 1 { word.to_string() } else { format!("{word}s") }
|
||||
}
|
||||
|
||||
fn bright_red(text: &str) -> String {
|
||||
text.if_supports_color(Stream::Stdout, |t| t.bright_red()).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
156
pacquet/crates/cli/src/cli_args/audit/signatures/tests.rs
Normal file
156
pacquet/crates/cli/src/cli_args/audit/signatures/tests.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use super::{
|
||||
PackageSignature, RegistryKey, SignaturePackage, SignatureVerificationResult,
|
||||
encode_package_name, parse_timestamp, render_signature_verification_result, verify_one,
|
||||
verify_package_signatures,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use p256::ecdsa::SigningKey;
|
||||
|
||||
fn signing_key() -> SigningKey {
|
||||
SigningKey::from_slice(&[0x42; 32]).expect("valid P-256 scalar")
|
||||
}
|
||||
|
||||
fn public_key_b64(key: &SigningKey) -> String {
|
||||
use p256::pkcs8::EncodePublicKey;
|
||||
let der = key.verifying_key().to_public_key_der().expect("encode SPKI");
|
||||
base64::engine::general_purpose::STANDARD.encode(der.as_bytes())
|
||||
}
|
||||
|
||||
fn sign_b64(key: &SigningKey, message: &str) -> String {
|
||||
use p256::ecdsa::{Signature, signature::Signer};
|
||||
let signature: Signature = key.sign(message.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(signature.to_der().as_bytes())
|
||||
}
|
||||
|
||||
fn package() -> SignaturePackage {
|
||||
SignaturePackage {
|
||||
name: "foo".to_string(),
|
||||
registry: "https://registry.example.com/".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ecdsa_key(key: &SigningKey, keyid: &str, expires: Option<&str>) -> RegistryKey {
|
||||
RegistryKey {
|
||||
expires: expires.map(str::to_string),
|
||||
key: public_key_b64(key),
|
||||
keyid: keyid.to_string(),
|
||||
keytype: "ecdsa-sha2-nistp256".to_string(),
|
||||
scheme: "ecdsa-sha2-nistp256".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(keyid: &str, sig: &str) -> PackageSignature {
|
||||
PackageSignature { keyid: keyid.to_string(), sig: sig.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_one_accepts_only_the_signed_message() {
|
||||
let key = signing_key();
|
||||
let public = public_key_b64(&key);
|
||||
let message = "foo@1.0.0:sha512-abc";
|
||||
|
||||
assert!(verify_one(&public, message, &sign_b64(&key, message)));
|
||||
assert!(!verify_one(&public, message, &sign_b64(&key, "foo@1.0.0:other")));
|
||||
assert!(!verify_one("not base64 ~~~", message, "also not base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_signature_verifies() {
|
||||
let key = signing_key();
|
||||
let package = package();
|
||||
let integrity = "sha512-abc";
|
||||
let message = format!("{}@{}:{integrity}", package.name, package.version);
|
||||
let signatures = vec![signature("k1", &sign_b64(&key, &message))];
|
||||
let keys = vec![ecdsa_key(&key, "k1", None)];
|
||||
|
||||
assert!(
|
||||
verify_package_signatures(&package, integrity, None, None, &signatures, &keys).is_none(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_yields_an_unknown_key_reason() {
|
||||
let package = package();
|
||||
let signatures = vec![signature("nope", "AA==")];
|
||||
|
||||
let issue =
|
||||
verify_package_signatures(&package, "sha512-abc", None, None, &signatures, &[]).unwrap();
|
||||
let reason = issue.reason.unwrap();
|
||||
assert!(reason.contains("no corresponding public key"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signature_reason_is_preferred_over_unknown_key() {
|
||||
let key = signing_key();
|
||||
let package = package();
|
||||
let integrity = "sha512-abc";
|
||||
let tampered = sign_b64(&key, "foo@1.0.0:tampered");
|
||||
let signatures = vec![signature("missing", "AA=="), signature("k1", &tampered)];
|
||||
let keys = vec![ecdsa_key(&key, "k1", None)];
|
||||
|
||||
let issue =
|
||||
verify_package_signatures(&package, integrity, None, None, &signatures, &keys).unwrap();
|
||||
let reason = issue.reason.unwrap();
|
||||
assert!(reason.contains("invalid registry signature"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_expiry_gates_only_when_published_after_expiry() {
|
||||
let key = signing_key();
|
||||
let package = package();
|
||||
let integrity = "sha512-abc";
|
||||
let message = format!("foo@1.0.0:{integrity}");
|
||||
let signatures = vec![signature("k1", &sign_b64(&key, &message))];
|
||||
let keys = vec![ecdsa_key(&key, "k1", Some("2020-01-01T00:00:00.000Z"))];
|
||||
|
||||
let published_after = verify_package_signatures(
|
||||
&package,
|
||||
integrity,
|
||||
Some("2021-01-01T00:00:00.000Z"),
|
||||
None,
|
||||
&signatures,
|
||||
&keys,
|
||||
);
|
||||
assert!(published_after.unwrap().reason.unwrap().contains("expired"));
|
||||
|
||||
assert!(
|
||||
verify_package_signatures(
|
||||
&package,
|
||||
integrity,
|
||||
Some("2019-01-01T00:00:00.000Z"),
|
||||
None,
|
||||
&signatures,
|
||||
&keys
|
||||
)
|
||||
.is_none(),
|
||||
"a key not yet expired at publish time stays usable",
|
||||
);
|
||||
assert!(
|
||||
verify_package_signatures(&package, integrity, None, None, &signatures, &keys).is_none(),
|
||||
"a missing publish time keeps the key usable",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_package_name_matches_encode_uri_component() {
|
||||
assert_eq!(encode_package_name("lodash"), "lodash");
|
||||
assert_eq!(encode_package_name("a.b-c_d"), "a.b-c_d");
|
||||
assert_eq!(encode_package_name("@scope/pkg"), "@scope%2Fpkg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timestamp_accepts_iso_and_rejects_garbage() {
|
||||
assert!(parse_timestamp("2020-01-01T00:00:00.000Z").is_some());
|
||||
assert!(parse_timestamp("nonsense").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_announces_absence_of_signing_keys() {
|
||||
let output = render_signature_verification_result(&SignatureVerificationResult::default());
|
||||
assert!(output.contains("audited 0 packages"), "{output}");
|
||||
assert!(
|
||||
output.contains("No dependencies were installed from a registry with signing keys"),
|
||||
"{output}",
|
||||
);
|
||||
}
|
||||
@@ -439,14 +439,219 @@ fn audit_defaults_to_low_and_ignores_info_for_exit_code() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_is_reported_as_unsupported() {
|
||||
fn audit_signatures_reports_verified_packages() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let key = signing_key();
|
||||
let integrity = "sha512-abc";
|
||||
let signature = sign_b64(&key, &format!("signed-pkg@1.0.0:{integrity}"));
|
||||
let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create();
|
||||
let packument_mock = registry
|
||||
.mock("GET", "/signed-pkg")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(packument_body("signed-pkg", "1.0.0", integrity, &signatures_json(&signature)))
|
||||
.create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_success(&output);
|
||||
let out = stdout(&output);
|
||||
assert!(out.contains("audited 1 package"), "{out}");
|
||||
assert!(out.contains("1 package has a verified registry signature"), "{out}");
|
||||
keys_mock.assert();
|
||||
packument_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_json_reports_counts() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let key = signing_key();
|
||||
let integrity = "sha512-abc";
|
||||
let signature = sign_b64(&key, &format!("signed-pkg@1.0.0:{integrity}"));
|
||||
let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create();
|
||||
let packument_mock = registry
|
||||
.mock("GET", "/signed-pkg")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(packument_body("signed-pkg", "1.0.0", integrity, &signatures_json(&signature)))
|
||||
.create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet
|
||||
.arg("audit")
|
||||
.arg("signatures")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.expect("run audit signatures");
|
||||
|
||||
assert_success(&output);
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&output)).expect("signatures JSON");
|
||||
assert_eq!(report["audited"], 1);
|
||||
assert_eq!(report["verified"], 1);
|
||||
assert_eq!(report["invalid"].as_array().expect("invalid array").len(), 0);
|
||||
assert_eq!(report["missing"].as_array().expect("missing array").len(), 0);
|
||||
keys_mock.assert();
|
||||
packument_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_flags_missing_signature() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let key = signing_key();
|
||||
let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create();
|
||||
let packument_mock = registry
|
||||
.mock("GET", "/signed-pkg")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(packument_body("signed-pkg", "1.0.0", "sha512-abc", "[]"))
|
||||
.create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1), "missing signatures should exit 1");
|
||||
let out = stdout(&output);
|
||||
assert!(out.contains("missing registry signature"), "{out}");
|
||||
assert!(out.contains("signed-pkg@1.0.0"), "{out}");
|
||||
keys_mock.assert();
|
||||
packument_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_flags_invalid_signature() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let key = signing_key();
|
||||
// Sign a different integrity than the packument advertises: the signature
|
||||
// is well-formed but will not validate over the published bytes.
|
||||
let signature = sign_b64(&key, "signed-pkg@1.0.0:sha512-tampered");
|
||||
let keys_mock = keys_mock(&mut registry, &public_key_b64(&key)).create();
|
||||
let packument_mock = registry
|
||||
.mock("GET", "/signed-pkg")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(packument_body(
|
||||
"signed-pkg",
|
||||
"1.0.0",
|
||||
"sha512-abc",
|
||||
&signatures_json(&signature),
|
||||
))
|
||||
.create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_eq!(output.status.code(), Some(1), "invalid signatures should exit 1");
|
||||
let out = stdout(&output);
|
||||
assert!(out.contains("invalid registry signature"), "{out}");
|
||||
assert!(out.contains("Someone might have tampered"), "{out}");
|
||||
keys_mock.assert();
|
||||
packument_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_skips_registry_without_signing_keys() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let keys_mock =
|
||||
registry.mock("GET", "/-/npm/v1/keys").with_status(404).with_body("not found").create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_success(&output);
|
||||
let out = stdout(&output);
|
||||
assert!(out.contains("audited 0 packages"), "{out}");
|
||||
assert!(
|
||||
out.contains("No dependencies were installed from a registry with signing keys"),
|
||||
"{out}",
|
||||
);
|
||||
keys_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_fails_when_keys_endpoint_errors() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let keys_mock = registry
|
||||
.mock("GET", "/-/npm/v1/keys")
|
||||
.with_status(500)
|
||||
.with_body("boom \u{1b}[31m\n")
|
||||
.create();
|
||||
write_signatures_workspace(&workspace, ®istry.url(), "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_failure(&output);
|
||||
let stderr = stderr(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL"), "stderr:\n{stderr}");
|
||||
assert!(stderr.contains("responded with 500"), "stderr:\n{stderr}");
|
||||
// The attacker-controlled registry body is escaped before it reaches the
|
||||
// terminal: the raw ESC byte must not survive.
|
||||
assert!(stderr.contains(r"boom \u{1b}[31m\u{a}"), "stderr:\n{stderr}");
|
||||
assert!(!stderr.contains('\u{1b}'), "stderr:\n{stderr}");
|
||||
keys_mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_redacts_registry_credentials_on_network_error() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
// A registry with embedded credentials pointed at a closed port: the keys
|
||||
// fetch fails at the transport layer, and the resulting error must not leak
|
||||
// the `user:pass@` userinfo into stderr.
|
||||
write_signatures_workspace(&workspace, "https://user:pass@127.0.0.1:1", "signed-pkg");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_failure(&output);
|
||||
let stderr = stderr(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_AUDIT_SIGNATURE_KEYS_FETCH_FAIL"), "stderr:\n{stderr}");
|
||||
assert!(!stderr.contains("user:pass"), "credentials leaked into stderr:\n{stderr}");
|
||||
assert!(!stderr.contains("pass@"), "credentials leaked into stderr:\n{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_errors_when_no_packages() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
fs::write(workspace.join(".npmrc"), "registry=https://registry.npmjs.org/\n")
|
||||
.expect("write .npmrc");
|
||||
fs::write(workspace.join("pnpm-workspace.yaml"), "fetchRetries: 0\n")
|
||||
.expect("write workspace manifest");
|
||||
write_minimal_manifest(&workspace);
|
||||
fs::write(
|
||||
workspace.join("pnpm-lock.yaml"),
|
||||
"
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
",
|
||||
)
|
||||
.expect("write lockfile");
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run audit signatures");
|
||||
|
||||
assert_failure(&output);
|
||||
assert!(stderr(&output).contains("ERR_PNPM_AUDIT_NO_PACKAGES"), "stderr:\n{}", stderr(&output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_signatures_rejects_extra_subcommand_argument() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
write_minimal_manifest(&workspace);
|
||||
|
||||
let output = pacquet.arg("audit").arg("signatures").output().expect("run pacquet audit");
|
||||
let output =
|
||||
pacquet.arg("audit").arg("signatures").arg("extra").output().expect("run pacquet audit");
|
||||
|
||||
assert_failure(&output);
|
||||
assert!(stderr(&output).contains("not supported yet"));
|
||||
assert!(stderr(&output).contains("ERR_PNPM_AUDIT_UNKNOWN_SUBCOMMAND"));
|
||||
assert!(stderr(&output).contains("Unknown audit subcommand: signatures extra"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -796,6 +1001,79 @@ fn write_minimal_manifest(workspace: &Path) {
|
||||
.expect("write package.json");
|
||||
}
|
||||
|
||||
const SIGNATURE_KEYID: &str = "SHA256:test";
|
||||
|
||||
fn signing_key() -> p256::ecdsa::SigningKey {
|
||||
p256::ecdsa::SigningKey::from_slice(&[0x42; 32]).expect("valid P-256 scalar")
|
||||
}
|
||||
|
||||
fn public_key_b64(key: &p256::ecdsa::SigningKey) -> String {
|
||||
use base64::Engine as _;
|
||||
use p256::pkcs8::EncodePublicKey;
|
||||
let der = key.verifying_key().to_public_key_der().expect("encode SPKI");
|
||||
base64::engine::general_purpose::STANDARD.encode(der.as_bytes())
|
||||
}
|
||||
|
||||
fn sign_b64(key: &p256::ecdsa::SigningKey, message: &str) -> String {
|
||||
use base64::Engine as _;
|
||||
use p256::ecdsa::{Signature, signature::Signer};
|
||||
let signature: Signature = key.sign(message.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(signature.to_der().as_bytes())
|
||||
}
|
||||
|
||||
fn keys_mock(registry: &mut mockito::Server, public_key_b64: &str) -> mockito::Mock {
|
||||
registry
|
||||
.mock("GET", "/-/npm/v1/keys")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(format!(
|
||||
r#"{{"keys":[{{"expires":null,"keyid":"{SIGNATURE_KEYID}","keytype":"ecdsa-sha2-nistp256","scheme":"ecdsa-sha2-nistp256","key":"{public_key_b64}"}}]}}"#,
|
||||
))
|
||||
}
|
||||
|
||||
fn signatures_json(signature_b64: &str) -> String {
|
||||
format!(r#"[{{"keyid":"{SIGNATURE_KEYID}","sig":"{signature_b64}"}}]"#)
|
||||
}
|
||||
|
||||
fn packument_body(name: &str, version: &str, integrity: &str, signatures_json: &str) -> String {
|
||||
format!(
|
||||
r#"{{"name":"{name}","versions":{{"{version}":{{"name":"{name}","version":"{version}","dist":{{"integrity":"{integrity}","tarball":"https://example.com/{name}-{version}.tgz","signatures":{signatures_json}}}}}}},"time":{{"{version}":"2020-01-01T00:00:00.000Z"}}}}"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_signatures_workspace(workspace: &Path, registry_url: &str, name: &str) {
|
||||
fs::write(workspace.join(".npmrc"), format!("registry={registry_url}/\n"))
|
||||
.expect("write .npmrc");
|
||||
fs::write(workspace.join("pnpm-workspace.yaml"), "fetchRetries: 0\n")
|
||||
.expect("write workspace manifest");
|
||||
fs::write(
|
||||
workspace.join("package.json"),
|
||||
format!(r#"{{"name":"sig-test","version":"1.0.0","dependencies":{{"{name}":"1.0.0"}}}}"#),
|
||||
)
|
||||
.expect("write package.json");
|
||||
fs::write(
|
||||
workspace.join("pnpm-lock.yaml"),
|
||||
format!(
|
||||
"
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
{name}:
|
||||
specifier: '1.0.0'
|
||||
version: '1.0.0'
|
||||
|
||||
snapshots:
|
||||
|
||||
{name}@1.0.0: {{}}
|
||||
",
|
||||
),
|
||||
)
|
||||
.expect("write lockfile");
|
||||
}
|
||||
|
||||
fn nerf(registry_url: &str) -> &str {
|
||||
registry_url.strip_prefix("http:").expect("mockito registry uses http")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user