Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/zpm-config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -113,7 +121,7 @@
"enableScripts": {
"type": "boolean",
"description": "Whether to run postinstall scripts",
"default": true
"default": false
},
"enableStrictSsl": {
"type": "boolean",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion packages/zpm/src/commands/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstallMode>,
Expand All @@ -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);
}

Comment thread
cursor[bot] marked this conversation as resolved.
let range_kind = if self.fixed {
RangeKind::Exact
} else if self.exact {
Expand Down Expand Up @@ -345,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))))
Expand Down
18 changes: 17 additions & 1 deletion packages/zpm/src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstallMode>,
Expand All @@ -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::<BTreeSet<_>>();
Expand Down Expand Up @@ -165,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))))
Expand All @@ -188,6 +200,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()
Expand Down
3 changes: 3 additions & 0 deletions packages/zpm/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
107 changes: 85 additions & 22 deletions packages/zpm/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -112,30 +113,71 @@ pub async fn diff_folders(original: &Path, user: &Path) -> Result<String, Error>
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<char> = 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<String>]) -> bool {
if patterns.is_empty() {
return false;
}

patterns.iter().any(|pattern| {
let regex_str = glob_to_regex(&pattern.value);
Regex::new(&regex_str).map_or(false, |re| re.is_match(url))
})
}

fn validate_repo_url(url: &str, config: &HttpConfig, approved_repos: &[Setting<String>]) -> Result<(), Error> {
let git_url
= GitUrl::parse(url)
.map_err(|_| Error::InvalidGitUrl(url.to_owned()))?;

let Some(host) = git_url.host else {
return Ok(());
};
if !matches_approved_git_repository(url, approved_repos) {
return Err(Error::ApprovedGitRepositoriesError(url.to_owned()));
}

let url
= format!("https://{}", host);
let url = Url::parse(&url)
.map_err(|_| Error::InvalidUrl(url.to_owned()))?;
if let Some(host) = git_url.host {
let host_url
= format!("https://{}", host);
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));
}
}

Ok(())
}

async fn ls_remote(repo: &GitSource, config: &HttpConfig) -> Result<BTreeMap<String, String>, Error> {
async fn ls_remote(repo: &GitSource, config: &HttpConfig, approved_repos: &[Setting<String>]) -> Result<BTreeMap<String, String>, 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())
Expand All @@ -159,28 +201,28 @@ async fn ls_remote(repo: &GitSource, config: &HttpConfig) -> Result<BTreeMap<Str
}).await
}

pub async fn resolve_git_treeish(git_range: &GitRange, config: &HttpConfig) -> Result<String, Error> {
Comment thread
cursor[bot] marked this conversation as resolved.
pub async fn resolve_git_treeish(git_range: &GitRange, config: &HttpConfig, approved_repos: &[Setting<String>]) -> Result<String, Error> {
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)
}
},

_ => {
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<String, Error> {
let refs = ls_remote(repo, config).await?;
async fn resolve_git_treeish_stricter(repo: &GitSource, treeish: GitTreeish, config: &HttpConfig, approved_repos: &[Setting<String>]) -> Result<String, Error> {
let refs = ls_remote(repo, config, approved_repos).await?;

match treeish {
GitTreeish::AnythingGoes(_) => {
Expand Down Expand Up @@ -258,6 +300,27 @@ 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 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<Error> = 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())
}));
}

Comment thread
cursor[bot] marked this conversation as resolved.
let clone_dir
= Path::temp_dir()?;

Expand All @@ -271,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).await?;
git_clone_into(source, commit, &clone_dir, http_config, approved_repos).await?;
Ok(clone_dir)
}

Expand All @@ -283,9 +346,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<String>]) -> 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())
Expand Down
2 changes: 1 addition & 1 deletion packages/zpm/src/resolvers/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params.git, &project.http_client.config).await?;
= git::resolve_git_treeish(&params.git, &project.http_client.config, &project.config.settings.approved_git_repositories).await?;

let git_reference = zpm_git::GitReference {
repo: params.git.repo.clone(),
Expand Down
12 changes: 7 additions & 5 deletions packages/zpm/src/resolvers/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
);
Expand All @@ -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);
}),
);
Expand Down
Loading