diff --git a/artifacts/cybersecurity/goals-and-requirements.yaml b/artifacts/cybersecurity/goals-and-requirements.yaml index c050deb..2ec1f64 100644 --- a/artifacts/cybersecurity/goals-and-requirements.yaml +++ b/artifacts/cybersecurity/goals-and-requirements.yaml @@ -1318,3 +1318,31 @@ artifacts: links: - type: verifies target: CD-18 + + - id: CD-19 + type: cybersecurity-design + title: BuildEnvironment capture and SLSA embedding + status: draft + description: > + BuildEnvironment struct auto-detecting rustc, cargo, Bazel, wasm-tools + versions, Nix flake lock hash, and host platform. Embeds as SLSA + provenance internalParameters. Supports both auto-detection and + CI environment variable configuration via WSC_* prefix. + links: + - type: refines + target: CR-4 + + - id: CV-27 + type: cybersecurity-verification + title: Build environment capture and serialization tests + status: draft + description: > + Test that BuildEnvironment::capture() detects available tools, + from_env_vars() reads WSC_* variables, to_slsa_internal_params() + produces valid JSON, and serialization roundtrips correctly. + fields: + method: test + tool: cargo-test + links: + - type: verifies + target: CD-19 diff --git a/artifacts/dev/features.yaml b/artifacts/dev/features.yaml index 0af705c..449d5d8 100644 --- a/artifacts/dev/features.yaml +++ b/artifacts/dev/features.yaml @@ -348,3 +348,19 @@ artifacts: links: - type: satisfies target: FEAT-3 + + - id: REQ-13 + type: requirement + title: Build environment metadata in SLSA provenance + status: approved + description: > + Attestation chain must capture build environment metadata including + Rust compiler version, Bazel version, Nix flake lock hash, and + host platform as SLSA provenance internal parameters. Addresses + Ferrocene RUSTC_CSTR_0030 for tool version verification. + fields: + priority: must + category: non-functional + links: + - type: satisfies + target: FEAT-4 diff --git a/src/cli/main.rs b/src/cli/main.rs index 7630595..6297385 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -598,6 +598,22 @@ fn start() -> Result<(), WSError> { .help("Type of transformation"), ), ) + .subcommand( + Command::new("build-env") + .about("Capture and display build environment attestation") + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .help("Output as JSON (for embedding in SLSA provenance)"), + ) + .arg( + Arg::new("from-env") + .long("from-env") + .action(ArgAction::SetTrue) + .help("Read versions from WSC_* environment variables"), + ), + ) .get_matches(); let verbose = matches.get_flag("verbose"); @@ -997,6 +1013,55 @@ fn start() -> Result<(), WSError> { handle_verify_chain_command(matches)?; } else if let Some(matches) = matches.subcommand_matches("attest") { handle_attest_command(matches)?; + } else if let Some(matches) = matches.subcommand_matches("build-env") { + let json_output = matches.get_flag("json"); + let from_env = matches.get_flag("from-env"); + + let env = if from_env { + wsc::build_env::BuildEnvironment::from_env_vars() + } else { + wsc::build_env::BuildEnvironment::capture() + }; + + if json_output { + let json = serde_json::to_string_pretty(&env) + .map_err(|e| WSError::InternalError(format!("JSON serialization failed: {}", e)))?; + println!("{}", json); + } else { + println!("Build Environment Attestation"); + println!("============================="); + if let Some(ref v) = env.rustc_version { + println!(" rustc: {}", v); + } + if let Some(ref v) = env.cargo_version { + println!(" cargo: {}", v); + } + if let Some(ref v) = env.bazel_version { + println!(" bazel: {}", v); + } + if let Some(ref v) = env.wasm_tools_version { + println!(" wasm-tools: {}", v); + } + if let Some(ref v) = env.nix_flake_lock_hash { + println!(" nix lock: {}", v); + } + if let Some(nix) = env.nix_build { + println!(" nix shell: {}", nix); + } + if let Some(ref v) = env.host_platform { + println!(" platform: {}", v); + } + if let Some(ref v) = env.os_version { + println!(" os: {}", v); + } + for (tool, version) in &env.additional_tools { + let pad = " ".repeat(11usize.saturating_sub(tool.len())); + println!(" {}:{}{}", tool, pad, version); + } + if env.is_reproducible() { + println!("\n [reproducible: nix flake lock pinned]"); + } + } } else { return Err(WSError::UsageError("No subcommand specified")); } diff --git a/src/lib/src/build_env.rs b/src/lib/src/build_env.rs new file mode 100644 index 0000000..b842f17 --- /dev/null +++ b/src/lib/src/build_env.rs @@ -0,0 +1,349 @@ +//! Build environment attestation for SLSA provenance. +//! +//! Captures build environment metadata (toolchain versions, Bazel config, +//! Nix flake hash, platform info) and integrates it with SLSA provenance +//! as internal parameters. Addresses Ferrocene RUSTC_CSTR_0030 for +//! tool version verification. +//! +//! # Example +//! +//! ```ignore +//! use wsc::build_env::BuildEnvironment; +//! +//! let env = BuildEnvironment::capture(); +//! println!("Rust: {}", env.rustc_version.as_deref().unwrap_or("unknown")); +//! +//! // Embed in SLSA provenance +//! let params = env.to_slsa_internal_params(); +//! ``` + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::process::Command; + +/// Build environment metadata captured at build/sign time. +/// +/// Embedded as `internalParameters.buildEnvironment` in SLSA provenance. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildEnvironment { + /// Rust compiler version (output of `rustc --version`) + #[serde(skip_serializing_if = "Option::is_none")] + pub rustc_version: Option, + + /// Cargo version + #[serde(skip_serializing_if = "Option::is_none")] + pub cargo_version: Option, + + /// Bazel version (from .bazelversion or `bazel --version`) + #[serde(skip_serializing_if = "Option::is_none")] + pub bazel_version: Option, + + /// Nix flake lock hash (SHA-256 of flake.lock for reproducibility) + #[serde(skip_serializing_if = "Option::is_none")] + pub nix_flake_lock_hash: Option, + + /// Whether the build was run inside a Nix shell + #[serde(skip_serializing_if = "Option::is_none")] + pub nix_build: Option, + + /// wasm-tools version (if available) + #[serde(skip_serializing_if = "Option::is_none")] + pub wasm_tools_version: Option, + + /// Host platform (e.g., "aarch64-macos") + #[serde(skip_serializing_if = "Option::is_none")] + pub host_platform: Option, + + /// OS version string + #[serde(skip_serializing_if = "Option::is_none")] + pub os_version: Option, + + /// Additional tool versions (key: tool name, value: version string) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub additional_tools: HashMap, + + /// Capture timestamp (RFC 3339) + #[serde(skip_serializing_if = "Option::is_none")] + pub captured_at: Option, +} + +/// Run a command and return trimmed stdout, or None on any failure. +fn capture_command_output(cmd: &str, args: &[&str]) -> Option { + Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Find and read `.bazelversion` by walking up from the current directory. +fn read_bazel_version_file() -> Option { + let mut dir = std::env::current_dir().ok()?; + loop { + let candidate = dir.join(".bazelversion"); + if candidate.is_file() { + return std::fs::read_to_string(candidate) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } + if !dir.pop() { + return None; + } + } +} + +/// Compute SHA-256 hash of `flake.lock` if it exists. +fn hash_flake_lock() -> Option { + let mut dir = std::env::current_dir().ok()?; + loop { + let candidate = dir.join("flake.lock"); + if candidate.is_file() { + let bytes = std::fs::read(candidate).ok()?; + let hash = Sha256::digest(&bytes); + return Some(hex::encode(hash)); + } + if !dir.pop() { + return None; + } + } +} + +impl BuildEnvironment { + /// Auto-detect build environment by probing tools and files. + /// + /// Runs external commands (`rustc`, `cargo`, `bazel`, `wasm-tools`) + /// and reads configuration files (`.bazelversion`, `flake.lock`). + /// Never fails — missing tools produce `None` fields. + pub fn capture() -> Self { + let rustc_version = capture_command_output("rustc", &["--version"]); + let cargo_version = capture_command_output("cargo", &["--version"]); + + let bazel_version = read_bazel_version_file().or_else(|| { + capture_command_output("bazel", &["--version"]) + .and_then(|s| s.strip_prefix("bazel ").map(|v| v.to_string())) + }); + + let nix_flake_lock_hash = hash_flake_lock(); + + let nix_build = if std::env::var("IN_NIX_SHELL").is_ok() + || std::env::var("NIX_BUILD_TOP").is_ok() + { + Some(true) + } else { + None + }; + + let wasm_tools_version = capture_command_output("wasm-tools", &["--version"]); + + let host_platform = Some(format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)); + + let os_version = capture_command_output("uname", &["-sr"]) + .or_else(|| std::env::var("OS").ok()); + + let captured_at = Some(chrono::Utc::now().to_rfc3339()); + + Self { + rustc_version, + cargo_version, + bazel_version, + nix_flake_lock_hash, + nix_build, + wasm_tools_version, + host_platform, + os_version, + additional_tools: HashMap::new(), + captured_at, + } + } + + /// Read build environment from `WSC_*` environment variables. + /// + /// Falls back to `capture()` for any variables that are not set. + /// Useful in CI where tool paths may not be on `$PATH` but versions + /// are known. + /// + /// Recognized variables: + /// - `WSC_RUSTC_VERSION` + /// - `WSC_CARGO_VERSION` + /// - `WSC_BAZEL_VERSION` + /// - `WSC_NIX_FLAKE_LOCK_HASH` + /// - `WSC_WASM_TOOLS_VERSION` + pub fn from_env_vars() -> Self { + let mut env = Self::capture(); + + if let Ok(v) = std::env::var("WSC_RUSTC_VERSION") { + env.rustc_version = Some(v); + } + if let Ok(v) = std::env::var("WSC_CARGO_VERSION") { + env.cargo_version = Some(v); + } + if let Ok(v) = std::env::var("WSC_BAZEL_VERSION") { + env.bazel_version = Some(v); + } + if let Ok(v) = std::env::var("WSC_NIX_FLAKE_LOCK_HASH") { + env.nix_flake_lock_hash = Some(v); + } + if let Ok(v) = std::env::var("WSC_WASM_TOOLS_VERSION") { + env.wasm_tools_version = Some(v); + } + + env + } + + /// Convert to SLSA provenance `internalParameters` JSON value. + /// + /// Returns a JSON object suitable for embedding in + /// `BuildDefinition.internalParameters.buildEnvironment`. + pub fn to_slsa_internal_params(&self) -> serde_json::Value { + serde_json::json!({ + "buildEnvironment": self + }) + } + + /// Add a custom tool version entry. + pub fn with_tool(mut self, name: impl Into, version: impl Into) -> Self { + self.additional_tools.insert(name.into(), version.into()); + self + } + + /// Whether the build environment is reproducible (Nix flake lock pinned). + pub fn is_reproducible(&self) -> bool { + self.nix_flake_lock_hash.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_capture() { + let env = BuildEnvironment::capture(); + // rustc should be available in any Rust dev environment + assert!(env.rustc_version.is_some()); + assert!(env.cargo_version.is_some()); + assert!(env.host_platform.is_some()); + assert!(env.captured_at.is_some()); + } + + #[test] + fn test_from_env_vars_structure() { + // We can't safely set env vars in edition 2024 (set_var is unsafe), + // so verify the method works by checking it returns a valid struct + // with at least the auto-detected fields. + let env = BuildEnvironment::from_env_vars(); + // Should still detect rustc even without WSC_ vars set + assert!(env.rustc_version.is_some()); + assert!(env.host_platform.is_some()); + } + + #[test] + fn test_to_slsa_internal_params() { + let env = BuildEnvironment { + rustc_version: Some("rustc 1.90.0".to_string()), + cargo_version: Some("cargo 1.90.0".to_string()), + bazel_version: Some("8.5.1".to_string()), + nix_flake_lock_hash: Some("abc123".to_string()), + nix_build: Some(true), + wasm_tools_version: None, + host_platform: Some("aarch64-macos".to_string()), + os_version: None, + additional_tools: HashMap::new(), + captured_at: Some("2026-03-18T00:00:00Z".to_string()), + }; + + let params = env.to_slsa_internal_params(); + let be = ¶ms["buildEnvironment"]; + assert_eq!(be["rustcVersion"], "rustc 1.90.0"); + assert_eq!(be["bazelVersion"], "8.5.1"); + assert_eq!(be["nixFlakeLockHash"], "abc123"); + assert_eq!(be["nixBuild"], true); + assert_eq!(be["hostPlatform"], "aarch64-macos"); + // None fields should not be present + assert!(be.get("wasmToolsVersion").is_none()); + assert!(be.get("osVersion").is_none()); + } + + #[test] + fn test_serialization_roundtrip() { + let env = BuildEnvironment::capture(); + let json = serde_json::to_string_pretty(&env).unwrap(); + let parsed: BuildEnvironment = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.rustc_version, env.rustc_version); + assert_eq!(parsed.cargo_version, env.cargo_version); + assert_eq!(parsed.host_platform, env.host_platform); + } + + #[test] + fn test_with_tool() { + let env = BuildEnvironment::capture() + .with_tool("protoc", "3.21.0") + .with_tool("z3", "4.12.0"); + + assert_eq!( + env.additional_tools.get("protoc"), + Some(&"3.21.0".to_string()) + ); + assert_eq!( + env.additional_tools.get("z3"), + Some(&"4.12.0".to_string()) + ); + } + + #[test] + fn test_is_reproducible() { + let mut env = BuildEnvironment::capture(); + + // If flake.lock exists in the project, it may already be reproducible. + // Test both states explicitly. + env.nix_flake_lock_hash = None; + assert!(!env.is_reproducible()); + + env.nix_flake_lock_hash = Some("abc123".to_string()); + assert!(env.is_reproducible()); + } + + #[test] + fn test_skip_none_fields() { + let env = BuildEnvironment { + rustc_version: Some("rustc 1.90.0".to_string()), + cargo_version: None, + bazel_version: None, + nix_flake_lock_hash: None, + nix_build: None, + wasm_tools_version: None, + host_platform: None, + os_version: None, + additional_tools: HashMap::new(), + captured_at: None, + }; + + let json = serde_json::to_string(&env).unwrap(); + assert!(json.contains("rustcVersion")); + assert!(!json.contains("cargoVersion")); + assert!(!json.contains("bazelVersion")); + assert!(!json.contains("nixFlakeLockHash")); + assert!(!json.contains("additionalTools")); + assert!(!json.contains("capturedAt")); + } + + #[test] + fn test_capture_command_output_missing_tool() { + let result = capture_command_output("this-tool-definitely-does-not-exist-xyz", &["--version"]); + assert!(result.is_none()); + } + + #[test] + fn test_bazel_version_from_file() { + // This test depends on .bazelversion being present in the project + // Just verify it doesn't panic + let _ = read_bazel_version_file(); + } +} diff --git a/src/lib/src/lib.rs b/src/lib/src/lib.rs index f834df7..5663e5d 100644 --- a/src/lib/src/lib.rs +++ b/src/lib/src/lib.rs @@ -115,6 +115,14 @@ pub mod slsa; /// target platform, and compilation parameters. pub mod transcoding; +/// Build environment attestation for SLSA provenance +/// +/// Captures build environment metadata (Rust, Bazel, Nix versions, platform) +/// for embedding in SLSA provenance as internal parameters. Supports both +/// automatic detection and CI environment variable configuration via WSC_* +/// prefix. Addresses Ferrocene RUSTC_CSTR_0030 for tool version verification. +pub mod build_env; + /// HTTP client abstraction for sync/async support /// /// Provides a unified HTTP client interface using `maybe_async` for compile-time