diff --git a/e2e/cli/test_tool b/e2e/cli/test_tool index b43029fe21..9ceb2ac42c 100644 --- a/e2e/cli/test_tool +++ b/e2e/cli/test_tool @@ -18,3 +18,25 @@ assert_fail "mise tool INVALID_TOOL_NAME" mise use tiny@1.0.0 mise use tiny@1.1.0 assert "mise tool tiny --installed" "1.0.0 1.1.0" + +# Test security field exists in JSON output for aqua tool +mise tool aqua:cli/cli --json | jq -e '.security' || fail "security field not found in JSON" + +# Test security field is an array +mise tool aqua:cli/cli --json | jq -e '.security | type == "array"' || fail "security is not an array" + +# Test that security features have type field when present +mise tool aqua:cli/cli --json | jq -e '.security | if length > 0 then .[0].type else true end' || fail "security feature missing type field" + +# Test core plugin security features +# Node has checksum + gpg +mise tool node --json | jq -e '.security | length >= 1' || fail "node should have security features" +mise tool node --json | jq -e '.security[] | select(.type == "checksum")' || fail "node should have checksum" + +# Go has checksum +mise tool go --json | jq -e '.security[] | select(.type == "checksum")' || fail "go should have checksum" + +# Zig has checksum + minisign +mise tool zig --json | jq -e '.security[] | select(.type == "checksum")' || fail "zig should have checksum" +mise tool zig --json | jq -e '.security[] | select(.type == "minisign")' || fail "zig should have minisign" +mise tool zig --json | jq -e '.security[] | select(.type == "minisign") | .public_key' || fail "zig minisign should have public_key" diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs index 0b95cecde7..dc0bd46155 100644 --- a/src/backend/aqua.rs +++ b/src/backend/aqua.rs @@ -54,6 +54,61 @@ impl Backend for AquaBackend { .and_then(|p| p.description.clone()) } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + let pkg = match AQUA_REGISTRY.package(&self.ba.tool_name).await { + Ok(pkg) => pkg, + Err(_) => return vec![], + }; + + let mut features = vec![]; + + // Checksum + if let Some(checksum) = &pkg.checksum + && checksum.enabled() + { + features.push(SecurityFeature::Checksum { + algorithm: checksum.algorithm.as_ref().map(|a| a.to_string()), + }); + } + + // GitHub Attestations + if let Some(attestations) = &pkg.github_artifact_attestations + && attestations.enabled.unwrap_or(false) + { + features.push(SecurityFeature::GithubAttestations { + signer_workflow: attestations.signer_workflow.clone(), + }); + } + + // SLSA + if let Some(slsa) = &pkg.slsa_provenance + && slsa.enabled.unwrap_or(false) + { + features.push(SecurityFeature::Slsa); + } + + // Cosign (nested in checksum) + if let Some(checksum) = &pkg.checksum + && let Some(cosign) = &checksum.cosign + && cosign.enabled.unwrap_or(false) + { + features.push(SecurityFeature::Cosign); + } + + // Minisign + if let Some(minisign) = &pkg.minisign + && minisign.enabled.unwrap_or(false) + { + features.push(SecurityFeature::Minisign { + public_key: minisign.public_key.clone(), + }); + } + + features + } + fn ba(&self) -> &Arc { &self.ba } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 3cd2a3ef79..b190d6e8aa 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -120,6 +120,27 @@ impl VersionInfo { } } +/// Security feature information for a tool +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SecurityFeature { + Checksum { + #[serde(skip_serializing_if = "Option::is_none")] + algorithm: Option, + }, + GithubAttestations { + #[serde(skip_serializing_if = "Option::is_none")] + signer_workflow: Option, + }, + Slsa, + Cosign, + Minisign { + #[serde(skip_serializing_if = "Option::is_none")] + public_key: Option, + }, + Gpg, +} + static TOOLS: Mutex>> = Mutex::new(None); pub async fn load_tools() -> Result> { @@ -295,6 +316,9 @@ pub trait Backend: Debug + Send + Sync { async fn description(&self) -> Option { None } + async fn security_info(&self) -> Vec { + vec![] + } fn get_plugin_type(&self) -> Option { None } diff --git a/src/cli/tool.rs b/src/cli/tool.rs index 625487d321..db5d23cd52 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -1,3 +1,4 @@ +use crate::backend::SecurityFeature; use crate::ui::style; use eyre::Result; use itertools::Itertools; @@ -75,10 +76,10 @@ impl Tool { } }; - let description = if let Some(backend) = backend { - backend.description().await + let (description, security) = if let Some(backend) = &backend { + (backend.description().await, backend.security_info().await) } else { - None + (None, vec![]) }; let info = ToolInfo { backend: ba.full(), @@ -105,6 +106,7 @@ impl Tool { }), config_source: tvl.map(|tvl| tvl.source.clone()), tool_options: ba.opts(), + security, }; if self.json { @@ -242,6 +244,31 @@ impl Tool { .join(","), )); } + if info.security.is_empty() { + table.push(("Security:", "[none]".to_string())); + } else { + let security_str = info + .security + .iter() + .map(|f| match f { + SecurityFeature::Checksum { algorithm } => { + if let Some(alg) = algorithm { + format!("checksum ({})", alg) + } else { + "checksum".to_string() + } + } + SecurityFeature::GithubAttestations { .. } => { + "github_attestations".to_string() + } + SecurityFeature::Slsa => "slsa".to_string(), + SecurityFeature::Cosign => "cosign".to_string(), + SecurityFeature::Minisign { .. } => "minisign".to_string(), + SecurityFeature::Gpg => "gpg".to_string(), + }) + .join(", "); + table.push(("Security:", security_str)); + } let mut table = tabled::Table::new(table); table::default_style(&mut table, true); miseprintln!("{table}"); @@ -260,6 +287,7 @@ struct ToolInfo { active_versions: Option>, config_source: Option, tool_options: ToolVersionOptions, + security: Vec, } static AFTER_LONG_HELP: &str = color_print::cstr!( diff --git a/src/plugins/core/bun.rs b/src/plugins/core/bun.rs index 032eb8f941..7a9ccf4393 100644 --- a/src/plugins/core/bun.rs +++ b/src/plugins/core/bun.rs @@ -96,6 +96,14 @@ impl Backend for BunPlugin { &self.ba } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }] + } + /// Override get_platform_key to include bun's compile-time variant (baseline, musl, etc.) /// This ensures lockfile lookups use the correct platform key that matches the variant fn get_platform_key(&self) -> String { diff --git a/src/plugins/core/deno.rs b/src/plugins/core/deno.rs index f85698bff5..a4acbf6574 100644 --- a/src/plugins/core/deno.rs +++ b/src/plugins/core/deno.rs @@ -94,6 +94,14 @@ impl Backend for DenoPlugin { &self.ba } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }] + } + async fn _list_remote_versions(&self, _config: &Arc) -> Result> { let versions: DenoVersions = HTTP_FETCH.json("https://deno.com/versions.json").await?; let versions = versions diff --git a/src/plugins/core/go.rs b/src/plugins/core/go.rs index 2968decf25..acce4ac2c6 100644 --- a/src/plugins/core/go.rs +++ b/src/plugins/core/go.rs @@ -195,6 +195,15 @@ impl Backend for GoPlugin { fn ba(&self) -> &Arc { &self.ba } + + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }] + } + async fn _list_remote_versions(&self, _config: &Arc) -> eyre::Result> { plugins::core::run_fetch_task_with_timeout(move || { let output = cmd!( diff --git a/src/plugins/core/node.rs b/src/plugins/core/node.rs index 30b21dbbb8..8c36f0b2e1 100644 --- a/src/plugins/core/node.rs +++ b/src/plugins/core/node.rs @@ -404,6 +404,21 @@ impl Backend for NodePlugin { &self.ba } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + let mut features = vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }]; + + // GPG verification is available for Node.js v20+ when gpg is installed + if Settings::get().node.gpg_verify != Some(false) { + features.push(SecurityFeature::Gpg); + } + + features + } + async fn _list_remote_versions_with_info( &self, _config: &Arc, diff --git a/src/plugins/core/ruby.rs b/src/plugins/core/ruby.rs index 58e186f284..e1032ed61e 100644 --- a/src/plugins/core/ruby.rs +++ b/src/plugins/core/ruby.rs @@ -592,6 +592,15 @@ impl Backend for RubyPlugin { fn ba(&self) -> &Arc { &self.ba } + + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }] + } + async fn _list_remote_versions_with_info( &self, _config: &Arc, diff --git a/src/plugins/core/swift.rs b/src/plugins/core/swift.rs index f0aafb918b..a6d2778010 100644 --- a/src/plugins/core/swift.rs +++ b/src/plugins/core/swift.rs @@ -159,6 +159,21 @@ impl Backend for SwiftPlugin { &self.ba } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + let mut features = vec![SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }]; + + // GPG verification is available on Linux when gpg is installed + if cfg!(target_os = "linux") && Settings::get().swift.gpg_verify != Some(false) { + features.push(SecurityFeature::Gpg); + } + + features + } + async fn _list_remote_versions_with_info( &self, _config: &Arc, diff --git a/src/plugins/core/zig.rs b/src/plugins/core/zig.rs index 89b65577d4..d15878a978 100644 --- a/src/plugins/core/zig.rs +++ b/src/plugins/core/zig.rs @@ -242,6 +242,19 @@ impl Backend for ZigPlugin { &self.ba } + async fn security_info(&self) -> Vec { + use crate::backend::SecurityFeature; + + vec![ + SecurityFeature::Checksum { + algorithm: Some("sha256".to_string()), + }, + SecurityFeature::Minisign { + public_key: Some(ZIG_MINISIGN_KEY.to_string()), + }, + ] + } + async fn _list_remote_versions_with_info( &self, _config: &Arc,