mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
feat: support staged publishes in trust scale (#12056)
Fixes #11887. Staged publishes now have a signal in the packument: `approver`. If this is set, the package is more trustworthy than a "trusted publisher" package, since it requires 2FA publish approvals. ## Changes **pnpm (TypeScript)** - `getTrustEvidence` recognizes `_npmUser.approver` and classifies it as a new `stagedPublish` trust evidence, ranked above `trustedPublisher` and `provenance`. - Trust-downgrade detection treats `stagedPublish` as the strongest rank, and the resolution verifier's PII-minimizing metadata projection retains the approver *signal* (without keeping the approver's name/email). **pacquet (Rust port)** - Ported the same staged-publish support: an `Approver` registry type, a `StagedPublish` trust evidence (rank 3 — above `TrustedPublisher`/`Provenance`), detection, pretty-printing, and the PII-stripping trust-meta projection. - Wired `trustPolicy='no-downgrade'` enforcement into the **resolver-time** path, not just the lockfile verifier. Previously pacquet only re-checked entries already in `pnpm-lock.yaml`; fresh resolutions weren't gated. The npm resolver now runs `fail_if_trust_downgraded` on each freshly picked version (full metadata is already forced under this policy), mirroring pnpm's resolver-time `failIfTrustDowngraded` call. - Ported the matching `trustChecks` tests for full parity with the TypeScript suite (staged-publish classification/downgrade, plus previously-unported `trustedPublisher → none`, no-evidence-anywhere, and exclude + missing-time cases). --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
7
.changeset/support-staged-publishes-trust-scale.md
Normal file
7
.changeset/support-staged-publishes-trust-scale.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@pnpm/resolving.registry.types": minor
|
||||
"@pnpm/resolving.npm-resolver": minor
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Staged publishes are now recognized in the trust scale. When a package version's registry metadata carries an `approver` field, it is treated as the strongest trust evidence (ranked above trusted publishers and provenance attestations), since staged publishes require 2FA publish approvals. This prevents false-positive trust downgrade errors when moving from a staged publish to a lower trust level [#11887](https://github.com/pnpm/pnpm/issues/11887).
|
||||
@@ -577,8 +577,10 @@ where
|
||||
// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/index.ts#L355-L383>.
|
||||
// `lockfile.is_none()` (writable-lockfile path) skips the
|
||||
// gate entirely — fresh local resolution is already filtered
|
||||
// by the resolver's per-version gate (when pacquet's
|
||||
// resolver lands). `trust_lockfile` (the OR of yaml's
|
||||
// by the resolver's per-version gate (`minimumReleaseAge` via
|
||||
// `ResolveResult::policy_violation`, `trustPolicy='no-downgrade'`
|
||||
// via the npm resolver's `fail_if_trust_downgraded_for_pick`).
|
||||
// `trust_lockfile` (the OR of yaml's
|
||||
// `trustLockfile` and the `--trust-lockfile` CLI flag,
|
||||
// resolved in [`crate::cli_args::install::InstallArgs::run`])
|
||||
// is the opt-out for environments where the install can
|
||||
|
||||
@@ -231,6 +231,12 @@ pub enum InstallWithFreshLockfileError {
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE))]
|
||||
MinimumReleaseAgeExclude(#[error(source)] pacquet_config::version_policy::VersionPolicyError),
|
||||
|
||||
/// `trustPolicyExclude` patterns rejected at compile time.
|
||||
/// Mirrors upstream's `ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE`.
|
||||
#[display("Invalid value in trustPolicyExclude: {_0}")]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE))]
|
||||
TrustPolicyExclude(#[error(source)] pacquet_config::version_policy::VersionPolicyError),
|
||||
|
||||
/// `allowBuilds` patterns in `pnpm-workspace.yaml` couldn't be
|
||||
/// parsed. Same `VersionPolicyError` shape the frozen-lockfile
|
||||
/// path surfaces — see `InstallFrozenLockfileError::VersionPolicy`
|
||||
@@ -609,6 +615,23 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
|
||||
.transpose()
|
||||
.map_err(InstallWithFreshLockfileError::MinimumReleaseAgeExclude)?;
|
||||
|
||||
// `trustPolicy='no-downgrade'` config, threaded into every
|
||||
// resolve so the npm resolver re-applies the downgrade gate to
|
||||
// freshly picked versions. `full_metadata` above is already
|
||||
// forced on under this policy, so the picker hands the resolver
|
||||
// the per-version `time` + trust evidence the check reads.
|
||||
let trust_policy = match config.trust_policy {
|
||||
TrustPolicy::Off => None,
|
||||
TrustPolicy::NoDowngrade => Some(TrustPolicy::NoDowngrade),
|
||||
};
|
||||
let trust_policy_exclude = config
|
||||
.trust_policy_exclude
|
||||
.as_deref()
|
||||
.filter(|patterns| !patterns.is_empty())
|
||||
.map(pacquet_config::version_policy::create_package_version_policy)
|
||||
.transpose()
|
||||
.map_err(InstallWithFreshLockfileError::TrustPolicyExclude)?;
|
||||
|
||||
// Seed `allPreferredVersions` from every importer's manifest +
|
||||
// the wanted lockfile's snapshots (when an existing one is
|
||||
// present and is being rewritten). Mirrors upstream's
|
||||
@@ -725,6 +748,9 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
|
||||
default_tag: Some("latest".to_string()),
|
||||
published_by,
|
||||
published_by_exclude: published_by_exclude.clone(),
|
||||
trust_policy,
|
||||
trust_policy_exclude: trust_policy_exclude.clone(),
|
||||
trust_policy_ignore_after: config.trust_policy_ignore_after,
|
||||
project_dir,
|
||||
lockfile_dir: lockfile_dir.to_path_buf(),
|
||||
workspace_packages: workspace_packages.clone(),
|
||||
|
||||
@@ -6,7 +6,7 @@ mod package_version;
|
||||
pub use package::Package;
|
||||
pub use package_distribution::{AttestationsDist, PackageDistribution, ProvenanceMeta};
|
||||
pub use package_tag::PackageTag;
|
||||
pub use package_version::{NpmUser, PackageVersion, TrustedPublisher};
|
||||
pub use package_version::{Approver, NpmUser, PackageVersion, TrustedPublisher};
|
||||
|
||||
use derive_more::{Display, Error, From};
|
||||
use miette::Diagnostic;
|
||||
|
||||
@@ -222,6 +222,43 @@ fn package_deserializes_full_provenance_packument() {
|
||||
assert_eq!(provenance.predicate_type.as_deref(), Some("https://slsa.dev/provenance/v1"));
|
||||
}
|
||||
|
||||
/// A staged publish carries `_npmUser.approver`; that field must
|
||||
/// round-trip through serde so the trust check can read it as the
|
||||
/// strongest (`stagedPublish`) evidence.
|
||||
#[test]
|
||||
fn package_deserializes_approver_packument() {
|
||||
let body = r#"{
|
||||
"name": "acme",
|
||||
"dist-tags": { "latest": "1.0.0" },
|
||||
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"name": "acme",
|
||||
"version": "1.0.0",
|
||||
"_npmUser": {
|
||||
"name": "alice",
|
||||
"email": "alice@example.com",
|
||||
"approver": {
|
||||
"name": "bob",
|
||||
"email": "bob@example.com"
|
||||
}
|
||||
},
|
||||
"dist": {
|
||||
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"shasum": "0000000000000000000000000000000000000000",
|
||||
"tarball": "https://registry/acme-1.0.0.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let pkg: Package = serde_json::from_str(body).expect("deserialize approver packument");
|
||||
let version = pkg.versions.get("1.0.0").expect("1.0.0 deserialized");
|
||||
let user = version.npm_user.as_ref().expect("_npmUser present");
|
||||
let approver = user.approver.as_ref().expect("approver present");
|
||||
assert_eq!(approver.name.as_deref(), Some("bob"));
|
||||
assert_eq!(approver.email.as_deref(), Some("bob@example.com"));
|
||||
}
|
||||
|
||||
/// A packument that doesn't ship `_npmUser` or `attestations` (the
|
||||
/// common case for older registries) still deserializes; the
|
||||
/// trust-evidence fields land as `None` and the trust check that
|
||||
|
||||
@@ -191,9 +191,9 @@ pub struct PeerDependencyMeta {
|
||||
}
|
||||
|
||||
/// `_npmUser` field on a per-version manifest. The verifier reads
|
||||
/// `trusted_publisher` to assign the higher of the two trust ranks
|
||||
/// (`trustedPublisher` > `provenance` > none). `name` / `email` are
|
||||
/// kept for round-trip parity.
|
||||
/// `approver` and `trusted_publisher` to assign the trust rank
|
||||
/// (`stagedPublish` > `trustedPublisher` > `provenance` > none).
|
||||
/// `name` / `email` are kept for round-trip parity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NpmUser {
|
||||
@@ -202,9 +202,24 @@ pub struct NpmUser {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approver: Option<Approver>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_publisher: Option<TrustedPublisher>,
|
||||
}
|
||||
|
||||
/// `_npmUser.approver` record on a per-version manifest. Its presence
|
||||
/// marks a staged publish — one that required a 2FA publish approval,
|
||||
/// the strongest trust signal. The verifier only checks for the
|
||||
/// field's presence; `name` / `email` are kept for round-trip parity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Approver {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// OIDC trusted-publisher record on `_npmUser.trustedPublisher`.
|
||||
/// The verifier only checks for the field's presence; the inner
|
||||
/// values are kept for round-trip parity.
|
||||
|
||||
@@ -24,7 +24,7 @@ use chrono::{DateTime, Utc};
|
||||
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
|
||||
use pacquet_lockfile::{LockfileResolution, PkgName};
|
||||
use pacquet_network::{AuthHeaders, ThrottledClient};
|
||||
use pacquet_registry::{NpmUser, Package, PackageDistribution, PackageVersion};
|
||||
use pacquet_registry::{Approver, NpmUser, Package, PackageDistribution, PackageVersion};
|
||||
use pacquet_resolving_resolver_base::{
|
||||
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
|
||||
};
|
||||
@@ -835,8 +835,9 @@ fn build_policy_snapshot(
|
||||
|
||||
/// Build a [`Package`] that retains only the fields
|
||||
/// [`fail_if_trust_downgraded`] reads: the package name, the per-version
|
||||
/// `time` map, and per-version trust evidence (`_npmUser.trustedPublisher`
|
||||
/// and `dist.attestations.provenance`). Drops everything else — dependency
|
||||
/// `time` map, and per-version trust evidence (`_npmUser.approver`,
|
||||
/// `_npmUser.trustedPublisher`, and `dist.attestations.provenance`).
|
||||
/// Drops everything else — dependency
|
||||
/// graphs, scripts, READMEs — so the per-install trust-meta cache stays
|
||||
/// bounded by the trust-evidence footprint, not the full packument size.
|
||||
///
|
||||
@@ -871,14 +872,20 @@ fn project_trust_package_version(version: &PackageVersion) -> PackageVersion {
|
||||
version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).map(|prov| {
|
||||
pacquet_registry::AttestationsDist { provenance: Some(prov.clone()), url: None }
|
||||
});
|
||||
// `get_trust_evidence` only reads `npm_user.trusted_publisher`; drop
|
||||
// the maintainer `name` / `email` PII so the projected cache entry
|
||||
// `get_trust_evidence` only reads `npm_user.approver` (presence) and
|
||||
// `npm_user.trusted_publisher`; drop the maintainer `name` / `email`
|
||||
// PII — including the approver's — so the projected cache entry
|
||||
// doesn't hold per-version publisher metadata that downstream
|
||||
// doesn't need.
|
||||
let npm_user =
|
||||
version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref()).map(|trusted| {
|
||||
NpmUser { name: None, email: None, trusted_publisher: Some(trusted.clone()) }
|
||||
});
|
||||
let approver = version.npm_user.as_ref().and_then(|user| user.approver.as_ref());
|
||||
let trusted_publisher =
|
||||
version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref());
|
||||
let npm_user = (approver.is_some() || trusted_publisher.is_some()).then(|| NpmUser {
|
||||
name: None,
|
||||
email: None,
|
||||
approver: approver.map(|_| Approver { name: None, email: None }),
|
||||
trusted_publisher: trusted_publisher.cloned(),
|
||||
});
|
||||
PackageVersion {
|
||||
// `fail_if_trust_downgraded` keys off the outer `meta.versions`
|
||||
// map and the version-level npm_user / attestations fields. The
|
||||
|
||||
@@ -24,15 +24,12 @@
|
||||
//! registry fetch when the lockfile-pinned tarball is already in the
|
||||
//! store. Pacquet today goes through the picker unconditionally;
|
||||
//! restoring the fast path is a separate item.
|
||||
//! - **Trust-policy enforcement.** The resolver-side
|
||||
//! `failIfTrustDowngraded` call is wired through the verifier crate
|
||||
//! only; the resolver path doesn't enforce it yet.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use node_semver::Version;
|
||||
use pacquet_config::version_policy::PackageVersionPolicy;
|
||||
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
|
||||
use pacquet_lockfile::{LockfileResolution, PkgName, PkgNameVer, TarballResolution};
|
||||
use pacquet_network::{AuthHeaders, ThrottledClient};
|
||||
use pacquet_registry::{Package, PackageVersion};
|
||||
@@ -52,6 +49,7 @@ use crate::{
|
||||
resolve_from_local_package, try_resolve_from_workspace,
|
||||
try_resolve_from_workspace_packages,
|
||||
},
|
||||
trust_checks::{TrustCheckOptions, fail_if_trust_downgraded},
|
||||
violation_codes::MINIMUM_RELEASE_AGE_VIOLATION_CODE,
|
||||
};
|
||||
|
||||
@@ -264,6 +262,15 @@ impl<Cache: PackageMetaCache + 'static> NpmResolver<Cache> {
|
||||
}
|
||||
};
|
||||
|
||||
// Resolver-time `trustPolicy='no-downgrade'` gate. Runs on the
|
||||
// registry pick before the workspace shadow, matching upstream's
|
||||
// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
|
||||
// call site. A downgrade is a hard error that aborts the
|
||||
// install, so it propagates as a `ResolveError` rather than the
|
||||
// soft [`ResolveResult::policy_violation`] used for
|
||||
// `minimumReleaseAge`.
|
||||
fail_if_trust_downgraded_for_pick(opts, &picked)?;
|
||||
|
||||
// Registry pick succeeded — prefer the workspace copy when it
|
||||
// matches (or is newer, or `preferWorkspacePackages` is on).
|
||||
// Mirrors upstream's
|
||||
@@ -620,6 +627,30 @@ pub(crate) fn build_resolve_result(
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolver-time `trustPolicy='no-downgrade'` check on a fresh pick.
|
||||
/// No-op unless the policy is `NoDowngrade`. When active, runs
|
||||
/// [`fail_if_trust_downgraded`] against the picked version using the
|
||||
/// full packument the picker fetched (forced to full metadata under
|
||||
/// this policy by the install layer) and propagates a downgrade as a
|
||||
/// hard [`ResolveError`]. Mirrors upstream's resolver-time
|
||||
/// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
|
||||
/// call.
|
||||
fn fail_if_trust_downgraded_for_pick(
|
||||
opts: &ResolveOptions,
|
||||
picked: &PickedFromRegistry,
|
||||
) -> Result<(), ResolveError> {
|
||||
if opts.trust_policy != Some(TrustPolicy::NoDowngrade) {
|
||||
return Ok(());
|
||||
}
|
||||
let trust_opts = TrustCheckOptions {
|
||||
trust_policy_exclude: opts.trust_policy_exclude.as_ref(),
|
||||
trust_policy_ignore_after_minutes: opts.trust_policy_ignore_after,
|
||||
now: None,
|
||||
};
|
||||
fail_if_trust_downgraded(&picked.meta, &picked.version.version.to_string(), &trust_opts)
|
||||
.map_err(|err| Box::new(err) as ResolveError)
|
||||
}
|
||||
|
||||
/// Resolver-time `minimumReleaseAge` check. Returns a violation entry
|
||||
/// when the picked version's publish timestamp falls past the policy
|
||||
/// cutoff and isn't excluded by name/version. Mirrors upstream's
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use chrono::TimeZone;
|
||||
use pacquet_config::TrustPolicy;
|
||||
use pacquet_lockfile::LockfileResolution;
|
||||
use pacquet_network::{AuthHeaders, ThrottledClient};
|
||||
use pacquet_resolving_resolver_base::{
|
||||
@@ -113,6 +114,48 @@ const JSR_PACKAGE_BODY: &str = r#"{
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// Packument where the earlier-published `1.0.0` carries the strongest
|
||||
/// trust evidence available here (`trustedPublisher` + provenance) and
|
||||
/// the later `1.1.0` carries none — a trust downgrade. Resolving
|
||||
/// `^1.0.0` picks `1.1.0` (the max), so the resolver-time gate must
|
||||
/// reject it under `trustPolicy='no-downgrade'`.
|
||||
const TRUST_DOWNGRADE_PACKAGE_BODY: &str = r#"{
|
||||
"name": "acme",
|
||||
"dist-tags": { "latest": "1.1.0" },
|
||||
"modified": "2025-01-15T12:00:00.000Z",
|
||||
"time": {
|
||||
"1.0.0": "2024-01-10T08:30:00.000Z",
|
||||
"1.1.0": "2024-12-10T08:30:00.000Z"
|
||||
},
|
||||
"versions": {
|
||||
"1.0.0": {
|
||||
"name": "acme",
|
||||
"version": "1.0.0",
|
||||
"_npmUser": {
|
||||
"name": "alice",
|
||||
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
|
||||
},
|
||||
"dist": {
|
||||
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"shasum": "0000000000000000000000000000000000000000",
|
||||
"tarball": "https://registry/acme-1.0.0.tgz",
|
||||
"attestations": {
|
||||
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"1.1.0": {
|
||||
"name": "acme",
|
||||
"version": "1.1.0",
|
||||
"dist": {
|
||||
"integrity": "sha512-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB==",
|
||||
"shasum": "1111111111111111111111111111111111111111",
|
||||
"tarball": "https://registry/acme-1.1.0.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_specifier_picks_max_in_range() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
@@ -220,6 +263,57 @@ async fn surfaces_min_release_age_violation_inline() {
|
||||
assert_eq!(violation.code, MINIMUM_RELEASE_AGE_VIOLATION_CODE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trust_downgrade_at_resolve_time_fails_under_no_downgrade() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _mock = server
|
||||
.mock("GET", "/acme")
|
||||
.with_status(200)
|
||||
.with_body(TRUST_DOWNGRADE_PACKAGE_BODY)
|
||||
.create_async()
|
||||
.await;
|
||||
let registry = format!("{}/", server.url());
|
||||
let (resolver, _tempdir) = build_resolver(®istry);
|
||||
|
||||
// `^1.0.0` picks 1.1.0 (the max), which has no trust evidence while
|
||||
// the earlier 1.0.0 shipped a trusted publisher — a downgrade. The
|
||||
// resolver-time gate must reject it as a hard error.
|
||||
let opts = ResolveOptions {
|
||||
trust_policy: Some(TrustPolicy::NoDowngrade),
|
||||
..ResolveOptions::default()
|
||||
};
|
||||
let wanted = WantedDependency {
|
||||
alias: Some("acme".to_string()),
|
||||
bare_specifier: Some("^1.0.0".to_string()),
|
||||
..WantedDependency::default()
|
||||
};
|
||||
let err = resolver.resolve(&wanted, &opts).await.expect_err("trust downgrade should fail");
|
||||
assert!(err.to_string().contains("trust downgrade"), "got {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trust_downgrade_ignored_when_trust_policy_off() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _mock = server
|
||||
.mock("GET", "/acme")
|
||||
.with_status(200)
|
||||
.with_body(TRUST_DOWNGRADE_PACKAGE_BODY)
|
||||
.create_async()
|
||||
.await;
|
||||
let registry = format!("{}/", server.url());
|
||||
let (resolver, _tempdir) = build_resolver(®istry);
|
||||
|
||||
// Same downgrade history, but without `trustPolicy='no-downgrade'`
|
||||
// the gate never runs and 1.1.0 resolves cleanly.
|
||||
let wanted = WantedDependency {
|
||||
alias: Some("acme".to_string()),
|
||||
bare_specifier: Some("^1.0.0".to_string()),
|
||||
..WantedDependency::default()
|
||||
};
|
||||
let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap().unwrap();
|
||||
assert_eq!(result.name_ver.as_ref().expect("name_ver").suffix.to_string(), "1.1.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_latest_returns_picked_manifest() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//! For each it asks [`get_trust_evidence`] which "rank" of evidence
|
||||
//! the version exposes:
|
||||
//!
|
||||
//! - `stagedPublish` (rank 3) — `_npmUser.approver` is present. A
|
||||
//! staged publish required a 2FA publish approval, the strongest
|
||||
//! trust signal.
|
||||
//! - `trustedPublisher` (rank 2) — `_npmUser.trustedPublisher` and
|
||||
//! `dist.attestations.provenance` are both present.
|
||||
//! - `provenance` (rank 1) — `dist.attestations.provenance` is
|
||||
@@ -31,7 +34,8 @@ use node_semver::Version;
|
||||
use pacquet_config::version_policy::{PackageVersionPolicy, PolicyMatch};
|
||||
use pacquet_registry::{Package, PackageVersion};
|
||||
|
||||
/// Rank of supply-chain evidence on a single version.
|
||||
/// Rank of supply-chain evidence on a single version. Variants are
|
||||
/// declared weakest-first so the derived `Ord` matches `trust_rank`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TrustEvidence {
|
||||
/// `dist.attestations.provenance` is set.
|
||||
@@ -42,6 +46,10 @@ pub enum TrustEvidence {
|
||||
/// only counts as the stronger signal when the version also
|
||||
/// shipped a provenance attestation.
|
||||
TrustedPublisher,
|
||||
/// `_npmUser.approver` is set. The version was published through a
|
||||
/// staged publish requiring a 2FA approval — the strongest signal,
|
||||
/// ranked above a trusted publisher.
|
||||
StagedPublish,
|
||||
}
|
||||
|
||||
/// Failure surfaced by [`fail_if_trust_downgraded`]. Each variant
|
||||
@@ -181,12 +189,13 @@ pub fn fail_if_trust_downgraded(
|
||||
}
|
||||
|
||||
/// Map a [`TrustEvidence`] rank to upstream's numeric weight at
|
||||
/// [`trustChecks.ts:10-13`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L10-L13).
|
||||
/// [`trustChecks.ts:10-14`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/trustChecks.ts#L10-L14).
|
||||
/// Upstream uses `undefined` for "no evidence"; the Rust port uses
|
||||
/// `Option<TrustEvidence>` so callers compare ranks via
|
||||
/// `Option::map_or(0, trust_rank)`.
|
||||
fn trust_rank(evidence: TrustEvidence) -> u8 {
|
||||
match evidence {
|
||||
TrustEvidence::StagedPublish => 3,
|
||||
TrustEvidence::TrustedPublisher => 2,
|
||||
TrustEvidence::Provenance => 1,
|
||||
}
|
||||
@@ -194,6 +203,7 @@ fn trust_rank(evidence: TrustEvidence) -> u8 {
|
||||
|
||||
fn pretty_print_trust_evidence(evidence: Option<TrustEvidence>) -> &'static str {
|
||||
match evidence {
|
||||
Some(TrustEvidence::StagedPublish) => "staged publish",
|
||||
Some(TrustEvidence::TrustedPublisher) => "trusted publisher",
|
||||
Some(TrustEvidence::Provenance) => "provenance attestation",
|
||||
None => "no trust evidence",
|
||||
@@ -233,24 +243,32 @@ fn detect_strongest_trust_evidence_before(
|
||||
let Some(evidence) = get_trust_evidence(manifest) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(evidence, TrustEvidence::TrustedPublisher) {
|
||||
return Some(TrustEvidence::TrustedPublisher);
|
||||
}
|
||||
// First provenance hit sticks; a later trusted-publisher
|
||||
// hit would have returned above.
|
||||
if best.is_none() {
|
||||
best = Some(TrustEvidence::Provenance);
|
||||
// Keep the highest-ranked evidence seen so far. Don't short-
|
||||
// circuit on a mid-rank hit: a later version may carry stronger
|
||||
// evidence, and missing it would let a real downgrade slip
|
||||
// through. Only `StagedPublish` — the maximum rank — ends the
|
||||
// walk early.
|
||||
if best.is_none_or(|current| trust_rank(evidence) > trust_rank(current)) {
|
||||
best = Some(evidence);
|
||||
if evidence == TrustEvidence::StagedPublish {
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// `_npmUser.trustedPublisher` outranks `dist.attestations.provenance`
|
||||
/// `_npmUser.approver` (a staged publish) outranks everything; failing
|
||||
/// that, `_npmUser.trustedPublisher` outranks `dist.attestations.provenance`
|
||||
/// only when the version also carries a provenance attestation;
|
||||
/// otherwise the publisher flag is ignored and the version falls back
|
||||
/// to the provenance rank or `None`. Mirrors pnpm's
|
||||
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/fea5fd41da/resolving/npm-resolver/src/trustChecks.ts#L119-L127).
|
||||
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/trustChecks.ts#L123-L134).
|
||||
pub fn get_trust_evidence(version: &PackageVersion) -> Option<TrustEvidence> {
|
||||
let has_approver = version.npm_user.as_ref().and_then(|user| user.approver.as_ref()).is_some();
|
||||
if has_approver {
|
||||
return Some(TrustEvidence::StagedPublish);
|
||||
}
|
||||
let has_provenance =
|
||||
version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).is_some();
|
||||
let has_trusted_publisher =
|
||||
|
||||
@@ -9,21 +9,26 @@ enum Evidence {
|
||||
None,
|
||||
Provenance,
|
||||
TrustedPublisher,
|
||||
StagedPublish,
|
||||
}
|
||||
|
||||
/// Build a JSON object for a single version with the trust-evidence
|
||||
/// shape the verifier reads (`_npmUser.trustedPublisher` or
|
||||
/// `dist.attestations.provenance`). A `TrustedPublisher` fixture
|
||||
/// includes both fields: per `get_trust_evidence`, the publisher
|
||||
/// flag only outranks plain provenance when the version also ships a
|
||||
/// provenance attestation.
|
||||
/// shape the verifier reads (`_npmUser.approver`,
|
||||
/// `_npmUser.trustedPublisher`, or `dist.attestations.provenance`). A
|
||||
/// `TrustedPublisher` fixture includes both fields: per
|
||||
/// `get_trust_evidence`, the publisher flag only outranks plain
|
||||
/// provenance when the version also ships a provenance attestation. A
|
||||
/// `StagedPublish` fixture carries an `approver`, the strongest signal.
|
||||
fn version_json(name: &str, version: &str, evidence: Evidence) -> serde_json::Value {
|
||||
let mut dist = serde_json::json!({
|
||||
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"shasum": "0000000000000000000000000000000000000000",
|
||||
"tarball": format!("https://registry/{name}-{version}.tgz")
|
||||
});
|
||||
if matches!(evidence, Evidence::Provenance | Evidence::TrustedPublisher) {
|
||||
if matches!(
|
||||
evidence,
|
||||
Evidence::Provenance | Evidence::TrustedPublisher | Evidence::StagedPublish,
|
||||
) {
|
||||
dist["attestations"] = serde_json::json!({
|
||||
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
|
||||
});
|
||||
@@ -39,6 +44,11 @@ fn version_json(name: &str, version: &str, evidence: Evidence) -> serde_json::Va
|
||||
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
|
||||
});
|
||||
}
|
||||
if matches!(evidence, Evidence::StagedPublish) {
|
||||
version_obj["_npmUser"] = serde_json::json!({
|
||||
"approver": { "name": "approver", "email": "approver@example.com" }
|
||||
});
|
||||
}
|
||||
version_obj
|
||||
}
|
||||
|
||||
@@ -87,6 +97,23 @@ fn trusted_publisher_to_provenance_downgrade_fails() {
|
||||
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Earlier version had `stagedPublish` (an approver), current version
|
||||
/// has only `trustedPublisher` → DOWNGRADE. Staged publish outranks a
|
||||
/// trusted publisher.
|
||||
#[test]
|
||||
fn staged_publish_to_trusted_publisher_downgrade_fails() {
|
||||
let meta = make_package(
|
||||
"acme",
|
||||
&[
|
||||
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::StagedPublish),
|
||||
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
|
||||
],
|
||||
);
|
||||
let err = fail_if_trust_downgraded(&meta, "2.0.0", &TrustCheckOptions::default())
|
||||
.expect_err("staged-publish → trusted-publisher is a downgrade");
|
||||
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Earlier version had `provenance`, current version has no
|
||||
/// evidence at all → DOWNGRADE.
|
||||
#[test]
|
||||
@@ -103,6 +130,41 @@ fn provenance_to_unsigned_downgrade_fails() {
|
||||
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Earlier version had `trustedPublisher`, current version has no
|
||||
/// evidence at all → DOWNGRADE. Mirrors upstream's "downgrading from
|
||||
/// trustedPublisher to none" case, with a third unsigned version
|
||||
/// before the publisher version to exercise the full history walk.
|
||||
#[test]
|
||||
fn trusted_publisher_to_unsigned_downgrade_fails() {
|
||||
let meta = make_package(
|
||||
"acme",
|
||||
&[
|
||||
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
|
||||
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
|
||||
("3.0.0", "2025-03-01T00:00:00.000Z", Evidence::None),
|
||||
],
|
||||
);
|
||||
let err = fail_if_trust_downgraded(&meta, "3.0.0", &TrustCheckOptions::default())
|
||||
.expect_err("trusted-publisher → no evidence is a downgrade");
|
||||
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// No version in the history carries any trust evidence, so there is
|
||||
/// no baseline to downgrade from → passes. Mirrors upstream's
|
||||
/// "succeeds when no versions have attestation".
|
||||
#[test]
|
||||
fn no_evidence_anywhere_passes() {
|
||||
let meta = make_package(
|
||||
"acme",
|
||||
&[
|
||||
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
|
||||
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::None),
|
||||
],
|
||||
);
|
||||
fail_if_trust_downgraded(&meta, "2.0.0", &TrustCheckOptions::default())
|
||||
.expect("no evidence anywhere → no downgrade possible");
|
||||
}
|
||||
|
||||
/// Equal-rank evidence (provenance → provenance) is not a
|
||||
/// downgrade.
|
||||
#[test]
|
||||
@@ -266,6 +328,44 @@ fn exclude_exact_version_short_circuits_check() {
|
||||
assert!(err.is_none(), "1.0.0 has its own trusted-publisher → passes");
|
||||
}
|
||||
|
||||
/// An excluded `name@version` short-circuits *before* the `time`
|
||||
/// lookup, so a packument with no `time` map still passes rather than
|
||||
/// surfacing `TrustCheckFailed`. Pins the ordering of the exclude
|
||||
/// check ahead of the time assertion. Mirrors upstream's "does not
|
||||
/// fail with ERR_PNPM_MISSING_TIME when package@version is excluded".
|
||||
#[test]
|
||||
fn exclude_exact_version_with_missing_time_does_not_fail() {
|
||||
let mut meta = make_package("acme", &[("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None)]);
|
||||
if let Some(time) = meta.time.as_mut() {
|
||||
time.clear();
|
||||
}
|
||||
let exclude = create_package_version_policy(["acme@1.0.0"]).unwrap();
|
||||
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
|
||||
fail_if_trust_downgraded(&meta, "1.0.0", &opts)
|
||||
.expect("excluded version short-circuits before the missing-time check");
|
||||
}
|
||||
|
||||
/// Same as above, but the whole package name is excluded. Mirrors
|
||||
/// upstream's "does not fail with ERR_PNPM_MISSING_TIME when package
|
||||
/// name is excluded".
|
||||
#[test]
|
||||
fn exclude_package_name_with_missing_time_does_not_fail() {
|
||||
let mut meta = make_package(
|
||||
"acme",
|
||||
&[
|
||||
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
|
||||
("2.0.0", "2025-02-01T00:00:00.000Z", Evidence::None),
|
||||
],
|
||||
);
|
||||
if let Some(time) = meta.time.as_mut() {
|
||||
time.clear();
|
||||
}
|
||||
let exclude = create_package_version_policy(["acme"]).unwrap();
|
||||
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
|
||||
fail_if_trust_downgraded(&meta, "2.0.0", &opts)
|
||||
.expect("excluded package short-circuits before the missing-time check");
|
||||
}
|
||||
|
||||
/// Missing `time` entry for the target version surfaces as
|
||||
/// `TrustCheckFailed`. Mirrors upstream's
|
||||
/// `Missing time for version X of Y` PnpmError.
|
||||
@@ -355,6 +455,24 @@ mod get_trust_evidence {
|
||||
));
|
||||
}
|
||||
|
||||
/// `_npmUser.approver` ranks as `StagedPublish`, the strongest
|
||||
/// evidence.
|
||||
#[test]
|
||||
fn approver_ranks_as_staged_publish() {
|
||||
let version = version_json("acme", "1.0.0", Evidence::StagedPublish);
|
||||
assert!(matches!(get_trust_evidence(&parse(version)), Some(TrustEvidence::StagedPublish)));
|
||||
}
|
||||
|
||||
/// `_npmUser.approver` wins even when `trustedPublisher` and
|
||||
/// provenance are also present — staged publish takes priority.
|
||||
#[test]
|
||||
fn approver_outranks_trusted_publisher() {
|
||||
let mut version = version_json("acme", "1.0.0", Evidence::TrustedPublisher);
|
||||
version["_npmUser"]["approver"] =
|
||||
serde_json::json!({ "name": "approver", "email": "approver@example.com" });
|
||||
assert!(matches!(get_trust_evidence(&parse(version)), Some(TrustEvidence::StagedPublish)));
|
||||
}
|
||||
|
||||
/// `dist.attestations.provenance` alone ranks as `Provenance`.
|
||||
#[test]
|
||||
fn provenance_alone_ranks_as_provenance() {
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::{collections::BTreeMap, future::Future, path::PathBuf, pin::Pin, sync::
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use derive_more::{Display, From};
|
||||
use pacquet_config::version_policy::PackageVersionPolicy;
|
||||
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
|
||||
use pacquet_lockfile::{LockfileResolution, PkgNameVer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -215,6 +215,21 @@ pub struct ResolveOptions {
|
||||
/// Per-package exclude policy for the maturity filter. `None`
|
||||
/// applies the filter uniformly.
|
||||
pub published_by_exclude: Option<PackageVersionPolicy>,
|
||||
/// `trustPolicy='no-downgrade'` gate. When `Some(NoDowngrade)`, the
|
||||
/// npm resolver rejects a freshly picked version whose trust
|
||||
/// evidence is weaker than an earlier-published version's — the
|
||||
/// resolver-time counterpart to the lockfile verifier's check.
|
||||
/// `None`/`Some(Off)` disables it. Mirrors pnpm's resolver-time
|
||||
/// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/372cae6a55/resolving/npm-resolver/src/index.ts#L548-L550)
|
||||
/// call, gated on `opts.trustPolicy === 'no-downgrade'`.
|
||||
pub trust_policy: Option<TrustPolicy>,
|
||||
/// Per-package exclude policy for the trust gate. `None` applies
|
||||
/// the gate uniformly.
|
||||
pub trust_policy_exclude: Option<PackageVersionPolicy>,
|
||||
/// Max age, in minutes, before which the trust gate still applies.
|
||||
/// A picked version older than this skips the check. `None` always
|
||||
/// checks. Mirrors pnpm's `trustPolicyIgnoreAfter`.
|
||||
pub trust_policy_ignore_after: Option<u64>,
|
||||
/// `true` suppresses on-disk and in-memory cache write-back during
|
||||
/// resolution. Mirrors upstream's `dryRun` flag at the resolver
|
||||
/// boundary.
|
||||
|
||||
@@ -451,12 +451,23 @@ function projectTrustManifest (manifest: PackageInRegistry): PackageInRegistry {
|
||||
// reads them; cast away the unsoundness so callers see the same nominal
|
||||
// shape without the per-version dependency graph / scripts / README bulk
|
||||
// carrying through. `_npmUser` is similarly narrowed to just
|
||||
// `trustedPublisher` — the only sub-field the trust check inspects — so
|
||||
// we don't keep maintainer name/email PII resident in the cache.
|
||||
// `trustedPublisher` and `approver` — the only sub-fields the trust check
|
||||
// inspects — so we don't keep maintainer name/email PII resident in the
|
||||
// cache.
|
||||
const approver = manifest._npmUser?.approver
|
||||
const trustedPublisher = manifest._npmUser?.trustedPublisher
|
||||
const provenance = manifest.dist?.attestations?.provenance
|
||||
let npmUser: PackageInRegistry['_npmUser'] = undefined
|
||||
if (approver) {
|
||||
npmUser ||= {}
|
||||
npmUser.approver = {}
|
||||
}
|
||||
if (trustedPublisher) {
|
||||
npmUser ||= {}
|
||||
npmUser.trustedPublisher = trustedPublisher
|
||||
}
|
||||
return {
|
||||
_npmUser: trustedPublisher != null ? { trustedPublisher } : undefined,
|
||||
_npmUser: npmUser,
|
||||
dist: provenance != null
|
||||
? { attestations: { provenance } }
|
||||
: undefined,
|
||||
|
||||
@@ -5,9 +5,10 @@ import semver from 'semver'
|
||||
|
||||
import { assertMetaHasTime } from './pickPackageFromMeta.js'
|
||||
|
||||
type TrustEvidence = 'provenance' | 'trustedPublisher'
|
||||
type TrustEvidence = 'provenance' | 'trustedPublisher' | 'stagedPublish'
|
||||
|
||||
const TRUST_RANK = {
|
||||
stagedPublish: 3,
|
||||
trustedPublisher: 2,
|
||||
provenance: 1,
|
||||
} as const satisfies Record<TrustEvidence, number>
|
||||
@@ -81,6 +82,7 @@ export function failIfTrustDowngraded (
|
||||
|
||||
function prettyPrintTrustEvidence (trustEvidence: TrustEvidence | undefined): string {
|
||||
switch (trustEvidence) {
|
||||
case 'stagedPublish': return 'staged publish'
|
||||
case 'trustedPublisher': return 'trusted publisher'
|
||||
case 'provenance': return 'provenance attestation'
|
||||
default: return 'no trust evidence'
|
||||
@@ -107,16 +109,21 @@ function detectStrongestTrustEvidenceBeforeDate (
|
||||
const trustEvidence = getTrustEvidence(manifest)
|
||||
if (!trustEvidence) continue
|
||||
|
||||
if (trustEvidence === 'trustedPublisher') {
|
||||
return 'trustedPublisher'
|
||||
if (best === undefined || TRUST_RANK[trustEvidence] > TRUST_RANK[best]) {
|
||||
best = trustEvidence
|
||||
if (best === 'stagedPublish') {
|
||||
return best
|
||||
}
|
||||
}
|
||||
best ||= 'provenance'
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
export function getTrustEvidence (manifest: PackageInRegistry): TrustEvidence | undefined {
|
||||
if (manifest._npmUser?.approver) {
|
||||
return 'stagedPublish'
|
||||
}
|
||||
if (manifest._npmUser?.trustedPublisher && manifest.dist?.attestations?.provenance) {
|
||||
return 'trustedPublisher'
|
||||
}
|
||||
|
||||
@@ -94,6 +94,55 @@ describe('getTrustEvidence', () => {
|
||||
}
|
||||
expect(getTrustEvidence(manifest)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns stagedPublish when approver exists', () => {
|
||||
const manifest: PackageInRegistry = {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
_npmUser: {
|
||||
name: 'test-approver',
|
||||
email: 'user@example.com',
|
||||
approver: {
|
||||
name: 'test-approver',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
dist: {
|
||||
shasum: 'abc123',
|
||||
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
|
||||
},
|
||||
}
|
||||
expect(getTrustEvidence(manifest)).toBe('stagedPublish')
|
||||
})
|
||||
|
||||
test('returns stagedPublish when both approver and trustedPublisher exist', () => {
|
||||
const manifest: PackageInRegistry = {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
_npmUser: {
|
||||
name: 'test-approver',
|
||||
email: 'user@example.com',
|
||||
approver: {
|
||||
name: 'test-approver',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
trustedPublisher: {
|
||||
id: 'test-provider',
|
||||
oidcConfigId: 'oidc:test-config-123',
|
||||
},
|
||||
},
|
||||
dist: {
|
||||
shasum: 'abc123',
|
||||
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
|
||||
attestations: {
|
||||
provenance: {
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(getTrustEvidence(manifest)).toBe('stagedPublish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('failIfTrustDowngraded', () => {
|
||||
@@ -371,6 +420,64 @@ describe('failIfTrustDowngraded', () => {
|
||||
}).toThrow('High-risk trust downgrade')
|
||||
})
|
||||
|
||||
test('throws an error when downgrading from stagedPublish to trustedPublisher', () => {
|
||||
const meta: PackageMetaWithTime = {
|
||||
name: 'foo',
|
||||
'dist-tags': { latest: '2.0.0' },
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: 'foo',
|
||||
version: '1.0.0',
|
||||
_npmUser: {
|
||||
name: 'test-approver',
|
||||
email: 'approver@example.com',
|
||||
approver: {
|
||||
name: 'test-approver',
|
||||
email: 'approver@example.com',
|
||||
},
|
||||
},
|
||||
dist: {
|
||||
shasum: 'abc123',
|
||||
tarball: 'https://registry.example.com/foo/-/foo-1.0.0.tgz',
|
||||
attestations: {
|
||||
provenance: {
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'2.0.0': {
|
||||
name: 'foo',
|
||||
version: '2.0.0',
|
||||
_npmUser: {
|
||||
name: 'test-publisher',
|
||||
email: 'publisher@example.com',
|
||||
trustedPublisher: {
|
||||
id: 'test-provider',
|
||||
oidcConfigId: 'oidc:test-config-123',
|
||||
},
|
||||
},
|
||||
dist: {
|
||||
shasum: 'def456',
|
||||
tarball: 'https://registry.example.com/foo/-/foo-2.0.0.tgz',
|
||||
attestations: {
|
||||
provenance: {
|
||||
predicateType: 'https://slsa.dev/provenance/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
time: {
|
||||
'1.0.0': '2025-01-01T00:00:00.000Z',
|
||||
'2.0.0': '2025-02-01T00:00:00.000Z',
|
||||
},
|
||||
}
|
||||
expect(() => {
|
||||
failIfTrustDowngraded(meta, '2.0.0')
|
||||
}).toThrow('High-risk trust downgrade')
|
||||
})
|
||||
|
||||
test('succeeds when maintaining same trust level', () => {
|
||||
const meta: PackageMetaWithTime = {
|
||||
name: 'foo',
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface PackageInRegistry extends PackageManifest {
|
||||
_npmUser?: {
|
||||
name?: string
|
||||
email?: string
|
||||
approver?: {
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
trustedPublisher?: {
|
||||
id: string
|
||||
oidcConfigId: string
|
||||
|
||||
Reference in New Issue
Block a user