From 6cd59b5e89b56226dab8f18622bc599d915a99e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Mon, 18 May 2026 21:07:28 +0200 Subject: [PATCH 1/4] Port Berry security defaults: enableScripts, npmMinimalAgeGate, approvedGitRepositories Ports the default-changing portions of four Berry PRs: - berry#7089: `enableScripts` now defaults to `false` - berry#7135: `npmMinimalAgeGate` now defaults to `1d` (was unset), with `--no-time-gate` flag on `yarn add` and `yarn up` - berry#7091: Adds `approvedGitRepositories` setting (empty by default, blocking all git dependencies until explicitly approved) - berry#7090: Skipped (exec: protocol doesn't exist in zpm) Lockfile migration machinery is intentionally omitted. Co-Authored-By: Claude Opus 4.6 --- packages/zpm-config/schema.json | 15 ++++-- packages/zpm/src/commands/add.rs | 10 +++- packages/zpm/src/commands/up.rs | 14 +++++- packages/zpm/src/error.rs | 3 ++ packages/zpm/src/git.rs | 82 ++++++++++++++++++++++++------- packages/zpm/src/resolvers/git.rs | 2 +- packages/zpm/src/resolvers/npm.rs | 12 +++-- 7 files changed, 109 insertions(+), 29 deletions(-) diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 8a7d3585..bd00f36c 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -6,6 +6,14 @@ "enableAutoTypes": ["tsEnableAutoTypes"] }, "properties": { + "approvedGitRepositories": { + "type": "array", + "description": "Array of glob patterns for git repository URLs that are allowed to be fetched. An empty list blocks all git dependencies.", + "items": { + "type": "string" + }, + "default": [] + }, "changesetBaseRefs": { "type": "array", "description": "The list of git refs to use as base for changeset detection. Defaults to ['main', 'master'] if not set.", @@ -113,7 +121,7 @@ "enableScripts": { "type": "boolean", "description": "Whether to run postinstall scripts", - "default": true + "default": false }, "enableStrictSsl": { "type": "boolean", @@ -269,8 +277,9 @@ "description": "The registry to use when auditing packages via `yarn npm audit`" }, "npmMinimalAgeGate": { - "type": ["std::time::Duration", "null"], - "description": "Minimum age of a package version in minutes to be considered for installation. Can be used to prevent installing very new packages, either because they tend to be more likely to include accidental bugs, or because of supply-chain security concerns." + "type": "std::time::Duration", + "description": "Minimum age of a package version to be considered for installation. Can be used to prevent installing very new packages, either because they tend to be more likely to include accidental bugs, or because of supply-chain security concerns.", + "default": "1d" }, "npmPreapprovedPackages": { "type": "array", diff --git a/packages/zpm/src/commands/add.rs b/packages/zpm/src/commands/add.rs index 9160fe15..676bd716 100644 --- a/packages/zpm/src/commands/add.rs +++ b/packages/zpm/src/commands/add.rs @@ -200,6 +200,10 @@ pub struct Add { // --- + /// Disable the minimum release age check for this command + #[cli::option("--no-time-gate", default = false)] + no_time_gate: bool, + /// Select the artifacts this install will generate #[cli::option("--mode")] mode: Option, @@ -216,9 +220,13 @@ pub struct Add { impl Add { pub async fn execute(&self) -> Result<(), Error> { - let project + let mut project = project::Project::new(None).await?; + if self.no_time_gate { + project.config.settings.npm_minimal_age_gate.force(std::time::Duration::ZERO, zpm_config::Source::Cli); + } + let range_kind = if self.fixed { RangeKind::Exact } else if self.exact { diff --git a/packages/zpm/src/commands/up.rs b/packages/zpm/src/commands/up.rs index 80deed2d..ad64c3c1 100644 --- a/packages/zpm/src/commands/up.rs +++ b/packages/zpm/src/commands/up.rs @@ -75,6 +75,10 @@ pub struct Up { // --- + /// Disable the minimum release age check for this command + #[cli::option("--no-time-gate", default = false)] + no_time_gate: bool, + /// Change what artifacts this install will generate #[cli::option("--mode")] mode: Option, @@ -91,9 +95,13 @@ impl Up { return self.execute_recursive().await; } - let project + let mut project = Project::new(None).await?; + if self.no_time_gate { + project.config.settings.npm_minimal_age_gate.force(std::time::Duration::ZERO, zpm_config::Source::Cli); + } + let all_idents = project.workspaces.iter() .flat_map(|workspace| self.list_workspace_idents(workspace)) .collect::>(); @@ -188,6 +196,10 @@ impl Up { let mut project = Project::new(None).await?; + if self.no_time_gate { + project.config.settings.npm_minimal_age_gate.force(std::time::Duration::ZERO, zpm_config::Source::Cli); + } + let lockfile = project.lockfile()?; let enforced_resolutions = lockfile.resolutions.keys() diff --git a/packages/zpm/src/error.rs b/packages/zpm/src/error.rs index 3be36407..265c7cb3 100644 --- a/packages/zpm/src/error.rs +++ b/packages/zpm/src/error.rs @@ -123,6 +123,9 @@ pub enum Error { #[error("Request to '{0}' has been blocked because of your configuration settings.")] NetworkDisabledError(reqwest::Url), + #[error("Request to '{0}' has been blocked because it doesn't match any of the patterns in 'approvedGitRepositories'.")] + ApprovedGitRepositoriesError(String), + #[error("Unsafe http requests must be explicitly whitelisted in your configuration ({}).", .0.host_str().expect("\"http:\" URL should have a host"))] UnsafeHttpError(reqwest::Url), diff --git a/packages/zpm/src/git.rs b/packages/zpm/src/git.rs index 02765f0a..81bbe2bb 100644 --- a/packages/zpm/src/git.rs +++ b/packages/zpm/src/git.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use git_url_parse::GitUrl; use regex::Regex; use reqwest::Url; +use zpm_config::Setting; use zpm_git::{GitRange, GitSource, GitTreeish}; use zpm_primitives::AnonymousSemverRange; use zpm_utils::{repeat_until_ok, Path}; @@ -112,7 +113,46 @@ pub async fn diff_folders(original: &Path, user: &Path) -> Result Ok(diff) } -fn validate_repo_url(url: &str, config: &HttpConfig) -> Result<(), Error> { +fn glob_to_regex(glob: &str) -> String { + let mut result = String::from("^"); + let chars: Vec = glob.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '*' && i + 1 < chars.len() && chars[i + 1] == '*' { + result.push_str(".*"); + i += 2; + } else if chars[i] == '*' { + result.push_str("[^/]*"); + i += 1; + } else if chars[i] == '?' { + result.push_str("[^/]"); + i += 1; + } else { + if "\\^$.|+()[]{}".contains(chars[i]) { + result.push('\\'); + } + result.push(chars[i]); + i += 1; + } + } + + result.push('$'); + result +} + +fn matches_approved_git_repository(url: &str, patterns: &[Setting]) -> bool { + if patterns.is_empty() { + return false; + } + + patterns.iter().any(|pattern| { + let regex_str = glob_to_regex(&pattern.value); + Regex::new(®ex_str).map_or(false, |re| re.is_match(url)) + }) +} + +fn validate_repo_url(url: &str, config: &HttpConfig, approved_repos: &[Setting]) -> Result<(), Error> { let git_url = GitUrl::parse(url) .map_err(|_| Error::InvalidGitUrl(url.to_owned()))?; @@ -121,21 +161,25 @@ fn validate_repo_url(url: &str, config: &HttpConfig) -> Result<(), Error> { return Ok(()); }; - let url + let host_url = format!("https://{}", host); - let url = Url::parse(&url) - .map_err(|_| Error::InvalidUrl(url.to_owned()))?; + let host_url = Url::parse(&host_url) + .map_err(|_| Error::InvalidUrl(host_url.to_owned()))?; - if !config.is_network_enabled(&url) { - return Err(Error::NetworkDisabledError(url)); + if !config.is_network_enabled(&host_url) { + return Err(Error::NetworkDisabledError(host_url)); + } + + if !matches_approved_git_repository(url, approved_repos) { + return Err(Error::ApprovedGitRepositoriesError(url.to_owned())); } Ok(()) } -async fn ls_remote(repo: &GitSource, config: &HttpConfig) -> Result, Error> { +async fn ls_remote(repo: &GitSource, config: &HttpConfig, approved_repos: &[Setting]) -> Result, Error> { repeat_until_ok(repo.to_urls(), |url| async move { - validate_repo_url(&url, config)?; + validate_repo_url(&url, config, approved_repos)?; let output = ScriptEnvironment::new()? .with_env(make_git_env()) @@ -159,14 +203,14 @@ async fn ls_remote(repo: &GitSource, config: &HttpConfig) -> Result Result { +pub async fn resolve_git_treeish(git_range: &GitRange, config: &HttpConfig, approved_repos: &[Setting]) -> Result { match &git_range.treeish { GitTreeish::AnythingGoes(treeish) => { - if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Commit(treeish.clone()), config).await { + if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Commit(treeish.clone()), config, approved_repos).await { Ok(result) - } else if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Tag(treeish.clone()), config).await { + } else if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Tag(treeish.clone()), config, approved_repos).await { Ok(result) - } else if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Head(treeish.clone()), config).await { + } else if let Ok(result) = resolve_git_treeish_stricter(&git_range.repo, GitTreeish::Head(treeish.clone()), config, approved_repos).await { Ok(result) } else { Err(Error::InvalidGitSpecifier) @@ -174,13 +218,13 @@ pub async fn resolve_git_treeish(git_range: &GitRange, config: &HttpConfig) -> R }, _ => { - resolve_git_treeish_stricter(&git_range.repo, git_range.treeish.clone(), config).await + resolve_git_treeish_stricter(&git_range.repo, git_range.treeish.clone(), config, approved_repos).await }, } } -async fn resolve_git_treeish_stricter(repo: &GitSource, treeish: GitTreeish, config: &HttpConfig) -> Result { - let refs = ls_remote(repo, config).await?; +async fn resolve_git_treeish_stricter(repo: &GitSource, treeish: GitTreeish, config: &HttpConfig, approved_repos: &[Setting]) -> Result { + let refs = ls_remote(repo, config, approved_repos).await?; match treeish { GitTreeish::AnythingGoes(_) => { @@ -258,6 +302,8 @@ pub async fn clone_repository(context: &InstallContext<'_>, source: &GitSource, let project = context.project .expect("The project is required for cloning repositories"); + let approved_repos = &project.config.settings.approved_git_repositories; + let clone_dir = Path::temp_dir()?; @@ -271,7 +317,7 @@ pub async fn clone_repository(context: &InstallContext<'_>, source: &GitSource, .await .expect("The clone limiter semaphore should not be closed"); - git_clone_into(source, commit, &clone_dir, &project.http_client.config).await?; + git_clone_into(source, commit, &clone_dir, &project.http_client.config, approved_repos).await?; Ok(clone_dir) } @@ -283,9 +329,9 @@ async fn download_into(source: &GitSource, commit: &str, download_dir: &Path, ht Ok(None) } -async fn git_clone_into(source: &GitSource, commit: &str, clone_dir: &Path, config: &HttpConfig) -> Result<(), Error> { +async fn git_clone_into(source: &GitSource, commit: &str, clone_dir: &Path, config: &HttpConfig, approved_repos: &[Setting]) -> Result<(), Error> { repeat_until_ok(source.to_urls(), |clone_url| async move { - validate_repo_url(&clone_url, config)?; + validate_repo_url(&clone_url, config, approved_repos)?; ScriptEnvironment::new()? .with_env(make_git_env()) diff --git a/packages/zpm/src/resolvers/git.rs b/packages/zpm/src/resolvers/git.rs index e085fd0e..eb74a992 100644 --- a/packages/zpm/src/resolvers/git.rs +++ b/packages/zpm/src/resolvers/git.rs @@ -12,7 +12,7 @@ pub async fn resolve_descriptor(context: &InstallContext<'_>, descriptor: &Descr .expect("The project is required for resolving a git package"); let commit - = git::resolve_git_treeish(¶ms.git, &project.http_client.config).await?; + = git::resolve_git_treeish(¶ms.git, &project.http_client.config, &project.config.settings.approved_git_repositories).await?; let git_reference = zpm_git::GitReference { repo: params.git.repo.clone(), diff --git a/packages/zpm/src/resolvers/npm.rs b/packages/zpm/src/resolvers/npm.rs index d7460dff..7ada2bce 100644 --- a/packages/zpm/src/resolvers/npm.rs +++ b/packages/zpm/src/resolvers/npm.rs @@ -104,7 +104,8 @@ fn is_package_approved(context: &InstallContext<'_>, ident: &Ident, version: &zp let check_config = || project.config.settings.npm_preapproved_packages.iter().any(|setting| setting.value.check(ident, version)); - if let Some(minimal_age_gate) = project.config.settings.npm_minimal_age_gate.value { + let minimal_age_gate = project.config.settings.npm_minimal_age_gate.value; + if !minimal_age_gate.is_zero() { if release_time.map_or(false, |time| context.install_time < *time + minimal_age_gate) { return check_config(); } @@ -204,10 +205,11 @@ pub async fn resolve_semver_descriptor(context: &InstallContext<'_>, descriptor: } // Skip if the version is more recent than the minimum age gate - let time - = project.config.settings.npm_minimal_age_gate.value - .and_then(|_| registry_data.time.as_ref()) - .and_then(|map| map.get(version)); + let time = if !project.config.settings.npm_minimal_age_gate.value.is_zero() { + registry_data.time.as_ref().and_then(|map| map.get(version)) + } else { + None + }; if !is_package_approved(context, package_ident, version, time) { continue; From ea7f6b944c32c01c0ff96733b5b5fa9064c95eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 21 May 2026 21:38:28 +0200 Subject: [PATCH 2/4] Feedback --- packages/zpm/src/commands/add.rs | 4 +++ packages/zpm/src/commands/up.rs | 4 +++ packages/zpm/src/git.rs | 45 ++++++++++++++++++++++---------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/zpm/src/commands/add.rs b/packages/zpm/src/commands/add.rs index 676bd716..61c77a3f 100644 --- a/packages/zpm/src/commands/add.rs +++ b/packages/zpm/src/commands/add.rs @@ -353,6 +353,10 @@ impl Add { let mut project = project::Project::new(None).await?; + if self.no_time_gate { + project.config.settings.npm_minimal_age_gate.force(std::time::Duration::ZERO, zpm_config::Source::Cli); + } + let enforced_resolutions = resolutions.into_iter() .filter_map(|resolution| resolution.locator.map(|locator| (resolution.descriptor, Some(locator)))) diff --git a/packages/zpm/src/commands/up.rs b/packages/zpm/src/commands/up.rs index ad64c3c1..58a5f9b4 100644 --- a/packages/zpm/src/commands/up.rs +++ b/packages/zpm/src/commands/up.rs @@ -173,6 +173,10 @@ impl Up { let mut project = Project::new(None).await?; + if self.no_time_gate { + project.config.settings.npm_minimal_age_gate.force(std::time::Duration::ZERO, zpm_config::Source::Cli); + } + let enforced_resolutions = loose_resolutions.into_iter() .filter_map(|resolution| resolution.locator.map(|locator| (resolution.descriptor, Some(locator)))) diff --git a/packages/zpm/src/git.rs b/packages/zpm/src/git.rs index 81bbe2bb..65ebaeb5 100644 --- a/packages/zpm/src/git.rs +++ b/packages/zpm/src/git.rs @@ -157,23 +157,21 @@ fn validate_repo_url(url: &str, config: &HttpConfig, approved_repos: &[Setting, source: &GitSource, .expect("The project is required for cloning repositories"); let approved_repos = &project.config.settings.approved_git_repositories; + let http_config = &project.http_client.config; + + // Validate that at least one of the source's canonical URLs is approved + // before attempting any clone path. Without this, the GitHub tarball + // fast-path in `download_into` would bypass `approvedGitRepositories`. + let source_urls = source.to_urls(); + let mut last_validation_err: Option = None; + let mut any_approved = false; + for url in &source_urls { + match validate_repo_url(url, http_config, approved_repos) { + Ok(()) => { any_approved = true; break; } + Err(err) => { last_validation_err = Some(err); } + } + } + if !any_approved { + return Err(last_validation_err.unwrap_or_else(|| { + Error::ApprovedGitRepositoriesError(source_urls.first().cloned().unwrap_or_default()) + })); + } let clone_dir = Path::temp_dir()?; @@ -317,7 +334,7 @@ pub async fn clone_repository(context: &InstallContext<'_>, source: &GitSource, .await .expect("The clone limiter semaphore should not be closed"); - git_clone_into(source, commit, &clone_dir, &project.http_client.config, approved_repos).await?; + git_clone_into(source, commit, &clone_dir, http_config, approved_repos).await?; Ok(clone_dir) } From 9e05f8ad72ed8979b0dca745d2fde24afdfc7d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 21 May 2026 21:55:26 +0200 Subject: [PATCH 3/4] Updates test defaults --- .../pkg-tests-core/sources/utils/makeTemporaryEnv.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts index bf1b19f7..3e3e1c44 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/makeTemporaryEnv.ts @@ -42,6 +42,11 @@ const baseEnv = (nativePath: string, nativeHomePath: string, registryUrl: string [`NODE_OPTIONS`]: ``, // Shorter warmup for faster tests (production default is 1s) [`YARN_DAEMON_DEFAULT_WARMUP_PERIOD`]: `500ms`, + // Berry security defaults would otherwise break the existing test suite; + // dedicated tests for each feature override these via withConfig. + [`YARN_ENABLE_SCRIPTS`]: `true`, + [`YARN_APPROVED_GIT_REPOSITORIES`]: `**`, + [`YARN_NPM_MINIMAL_AGE_GATE`]: `0`, ...rcEnv, ...env, }); From 78799bf9905b336af3a88eec20416b342b862ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 21 May 2026 22:52:15 +0200 Subject: [PATCH 4/4] Fixes test --- .../sources/commands/install.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/install.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/install.test.ts index 2409c415..e986a7fc 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/install.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/install.test.ts @@ -880,7 +880,11 @@ describe(`Commands`, () => { enableScripts: false, }); - const {stdout} = await run(`install`, `--inline-builds`); + const {stdout} = await run(`install`, `--inline-builds`, { + env: { + YARN_ENABLE_SCRIPTS: `false`, + }, + }); expect(stdout).toMatch(/lists build scripts, but its build has been explicitly disabled/g); }), ); @@ -901,7 +905,12 @@ describe(`Commands`, () => { enableScripts: false, }); - const {stdout} = await run(`install`, `--inline-builds`); + const {stdout} = await run(`install`, `--inline-builds`, { + env: { + YARN_ENABLE_SCRIPTS: `false`, + }, + }); + expect(stdout).toMatch(/lists build scripts, but its build has been explicitly disabled/g); }), );