diff --git a/Cargo.toml b/Cargo.toml index 1ad87c4..ff7a8a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ ast-grep-core = "0.39" schemars = { version = "^1", features = ["derive"] } rust-embed = { version = "8.9", features = ["compression", "include-exclude"] } reqwest = { version = "0.12.4", features = ["rustls-tls"], default-features = false } +openssl = { version = "0.10", features = ["vendored"] } # Native async runtime and parallel processing tokio = { version = "1.0", features = ["fs", "rt", "rt-multi-thread", "macros", "signal"] } @@ -46,7 +47,7 @@ criterion = "0.5" proptest = "1.0" # CLI-specific dependencies -clap = { version = "4.5", features = ["derive", "env"] } +clap = { version = "4.5", features = ["derive", "env", "cargo"] } env_logger = "0.11" log = "0.4" walkdir = "2.0" @@ -64,7 +65,9 @@ serial_test = "3.0" atty = "0.2" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.8", features = ["v4"] } -sha2 = "0.10" +aws-lc-rs = "1.15.2" +git2 = "0.20.3" +relative-path = "2.0.1" url = "2.5" percent-encoding = "2.3" aws-sdk-iam = "1.89.0" diff --git a/iam-policy-autopilot-cli/src/commands.rs b/iam-policy-autopilot-cli/src/commands.rs index 2e45f6b..25060c5 100644 --- a/iam-policy-autopilot-cli/src/commands.rs +++ b/iam-policy-autopilot-cli/src/commands.rs @@ -3,8 +3,8 @@ //! all denial types with appropriate branching logic. use crate::{output, types::ExitCode}; +use clap::crate_version; use iam_policy_autopilot_access_denied::{ApplyError, ApplyOptions, DenialType}; - fn is_tty() -> bool { atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stderr) } @@ -143,6 +143,31 @@ async fn fix_access_denied_with_service( } } +pub fn print_version_info(verbose: bool) -> anyhow::Result<()> { + println!("{}", crate_version!()); + if verbose { + let boto3_version_metadata = + iam_policy_autopilot_policy_generation::api::get_boto3_version_info()?; + let botocore_version_metadata = + iam_policy_autopilot_policy_generation::api::get_botocore_version_info()?; + println!( + "boto3 version: commit_id={}, commit_tag={}, data_hash={}", + boto3_version_metadata.git_commit_hash, + boto3_version_metadata.git_tag.unwrap_or("None".to_string()), + boto3_version_metadata.data_hash + ); + println!( + "botocore version: commit_id={}, commit_tag={}, data_hash={}", + botocore_version_metadata.git_commit_hash, + botocore_version_metadata + .git_tag + .unwrap_or("None".to_string()), + botocore_version_metadata.data_hash + ); + } + Ok(()) +} + fn handle_apply_error(apply_error: ApplyError) -> ExitCode { match apply_error { ApplyError::UnsupportedDenialType => { diff --git a/iam-policy-autopilot-cli/src/main.rs b/iam-policy-autopilot-cli/src/main.rs index 9082697..c9158cc 100644 --- a/iam-policy-autopilot-cli/src/main.rs +++ b/iam-policy-autopilot-cli/src/main.rs @@ -36,6 +36,8 @@ mod types; use iam_policy_autopilot_mcp_server::{start_mcp_server, McpTransport}; use types::ExitCode; +use crate::commands::print_version_info; + /// Default port for mcp server for Http Transport static MCP_HTTP_DEFAULT_PORT: u16 = 8001; @@ -112,6 +114,7 @@ required for the operations you perform (e.g., KMS actions for S3 encryption)."; name = "iam-policy-autopilot", author, version, + disable_version_flag = true, about = "Generate IAM policies from source code and fix AccessDenied errors", long_about = "Unified tool that combines IAM policy generation from source code analysis \ with automatic AccessDenied error fixing. Supports three main operations:\n\n\ @@ -349,6 +352,16 @@ for direct integration with IDEs and tools. 'http' starts an HTTP server for net Only used when --transport=http. The server will bind to 127.0.0.1 (localhost) on the specified port.")] port: u16, }, + + #[command( + about = "Print version information.", + short_flag = 'V', + long_flag = "version" + )] + Version { + #[arg(long = "verbose", default_value_t = false, hide = true)] + verbose: bool, + }, } /// Initialize logging based on configuration @@ -611,6 +624,14 @@ async fn main() { } } } + + Commands::Version { verbose } => match print_version_info(verbose) { + Ok(()) => ExitCode::Success, + Err(e) => { + print_cli_command_error(e); + ExitCode::Error + } + }, }; process::exit(code.into()); diff --git a/iam-policy-autopilot-policy-generation/Cargo.toml b/iam-policy-autopilot-policy-generation/Cargo.toml index 4a9e5ca..0a6d469 100644 --- a/iam-policy-autopilot-policy-generation/Cargo.toml +++ b/iam-policy-autopilot-policy-generation/Cargo.toml @@ -28,6 +28,7 @@ tokio.workspace = true async-trait.workspace = true strsim.workspace = true + # Build dependencies [build-dependencies] serde.workspace = true @@ -40,6 +41,10 @@ tokio-util.workspace = true # JSON processing serde_json.workspace = true +aws-lc-rs.workspace = true +git2.workspace = true +relative-path.workspace = true + [features] default = [] integ-test = [] diff --git a/iam-policy-autopilot-policy-generation/build.rs b/iam-policy-autopilot-policy-generation/build.rs index da04430..761edcd 100644 --- a/iam-policy-autopilot-policy-generation/build.rs +++ b/iam-policy-autopilot-policy-generation/build.rs @@ -1,9 +1,15 @@ +use aws_lc_rs::digest::{Context, Digest, SHA256}; +use git2::{DescribeOptions, Repository}; +use relative_path::PathExt; +use relative_path::RelativePathBuf; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::env; use std::fs; +use std::io; use std::path::Path; +use std::path::PathBuf; /// Simplified service definition with fields removed #[derive(Debug, Clone, Serialize, Deserialize)] @@ -11,8 +17,8 @@ struct SimplifiedServiceDefinition { #[serde(skip_serializing_if = "Option::is_none")] version: Option, metadata: ServiceMetadata, - operations: HashMap, - shapes: HashMap, + operations: BTreeMap, + shapes: BTreeMap, } /// Service metadata from AWS service definitions @@ -37,8 +43,8 @@ struct SimplifiedOperation { struct SimplifiedShape { #[serde(rename = "type")] type_name: String, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - members: HashMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + members: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] required: Option>, } @@ -49,6 +55,60 @@ struct ShapeReference { shape: String, } +include!("src/shared_submodule_model.rs"); + +impl GitSubmoduleMetadata { + fn new(git_path: &Path, data_path: &PathBuf) -> GitSubmoduleMetadata { + let repository = Repository::open(git_path) + .unwrap_or_else(|_| panic!("Failed to open repository at path {:?}", git_path)); + GitSubmoduleMetadata { + git_commit_hash: get_repository_commit(&repository).unwrap_or_else(|_| { + panic!("Failed to get repository commit at path {:?}", git_path) + }), + git_tag: get_repository_tag(&repository) + .unwrap_or_else(|_| panic!("Failed to get repository tag at path {:?}", git_path)), + data_hash: format!( + "{:?}", + Self::sha2sum_recursive(data_path, data_path).unwrap_or_else(|_| panic!( + "Failed to compute checksum over data at path {:?}", + data_path + )) + ), + } + } + + /// Recursively computes a deterministic SHA-256 hash of a directory tree. Hashes each file's contents + /// and subdirectory recursively, then combines all hashes with their relative paths in sorted order + /// to produce a single hash representing the entire directory structure and contents. + fn sha2sum_recursive(cwd: &Path, root: &Path) -> Result> { + let mut hash_table: BTreeMap = BTreeMap::new(); + + let mut dir_entry_list = fs::read_dir(cwd)? + .map(|res| res.map(|e| e.path())) + .collect::, io::Error>>()?; + dir_entry_list.sort(); + + for entry_path in dir_entry_list { + let relt_path = entry_path.clone().relative_to(root)?; + if entry_path.is_dir() { + hash_table.insert(relt_path, Self::sha2sum_recursive(&entry_path, root)?); + } else { + let mut sha2_context = Context::new(&SHA256); + sha2_context.update(&fs::read(entry_path)?); + hash_table.insert(relt_path, sha2_context.finish()); + } + } + + let mut sha2_context = Context::new(&SHA256); + for (path, digest) in hash_table { + sha2_context.update(path.into_string().as_bytes()); + sha2_context.update(digest.as_ref()); + } + + Ok(sha2_context.finish()) + } +} + fn main() { println!("cargo:rerun-if-changed=resources/config/sdks/botocore-data"); println!("cargo:rerun-if-changed=resources/config/sdks/boto3"); @@ -123,6 +183,40 @@ fn main() { // Copy the boto3 directory to the workspace location copy_dir_recursive(&boto3_dir, workspace_boto3_embed_dir) .expect("Failed to copy boto3 simplified data"); + + let workspace_submodule_version_embed_dir = PathBuf::from("target/submodule-version-info"); + + // Remove existing directory if it exists + if workspace_submodule_version_embed_dir.exists() { + fs::remove_dir_all(&workspace_submodule_version_embed_dir) + .expect("Failed to remove existing submodule version directory"); + } + fs::create_dir_all(&workspace_submodule_version_embed_dir) + .expect("Failed to create submodule version directory"); + + let boto3_info = + GitSubmoduleMetadata::new(Path::new("resources/config/sdks/boto3"), &boto3_dir); + + let boto3_info_json = + serde_json::to_string(&boto3_info).expect("Failed to serialize boto3 version metadata"); + fs::write( + workspace_submodule_version_embed_dir.join("boto3_version.json"), + boto3_info_json, + ) + .expect("Failed to write boto3 version metadata"); + + let botocore_info = GitSubmoduleMetadata::new( + Path::new("resources/config/sdks/botocore-data"), + &simplified_dir, + ); + + let botocore_info_json = serde_json::to_string(&botocore_info) + .expect("Failed to serialize botocore version metadata"); + fs::write( + workspace_submodule_version_embed_dir.join("botocore_version.json"), + botocore_info_json, + ) + .expect("Failed to write botocore version metadata"); } fn process_botocore_data( @@ -297,8 +391,8 @@ fn extract_metadata( fn simplify_operations( operations_value: Option<&Value>, -) -> Result, Box> { - let mut simplified_operations = HashMap::new(); +) -> Result, Box> { + let mut simplified_operations = BTreeMap::new(); if let Some(Value::Object(operations)) = operations_value { for (op_name, op_value) in operations { @@ -313,8 +407,8 @@ fn simplify_operations( fn simplify_shapes( shapes_value: Option<&Value>, -) -> Result, Box> { - let mut simplified_shapes = HashMap::new(); +) -> Result, Box> { + let mut simplified_shapes = BTreeMap::new(); if let Some(Value::Object(shapes)) = shapes_value { for (shape_name, shape_value) in shapes { @@ -417,3 +511,29 @@ fn process_boto3_service_version( Ok(has_resources_file) } + +/// Performs git describe --exact-match --tags +fn get_repository_tag(repo: &Repository) -> Result, Box> { + let mut describe_options = DescribeOptions::new(); + describe_options.max_candidates_tags(0); + describe_options.describe_tags(); + + Ok(repo + .describe(&describe_options) + .map(|desc| { + Option::Some( + desc.format(Option::None) + .expect("Failed to format describe result"), + ) + }) + .unwrap_or_default()) +} + +fn get_repository_commit(repo: &Repository) -> Result> { + Ok(repo + .revparse_single("HEAD")? + .into_commit() + .expect("Failed to get HEAD commit hash") + .id() + .to_string()) +} diff --git a/iam-policy-autopilot-policy-generation/src/api/get_submodule_version.rs b/iam-policy-autopilot-policy-generation/src/api/get_submodule_version.rs new file mode 100644 index 0000000..aaf425d --- /dev/null +++ b/iam-policy-autopilot-policy-generation/src/api/get_submodule_version.rs @@ -0,0 +1,28 @@ +use crate::errors::Result; +use crate::{api::model::GitSubmoduleMetadata, embedded_data::GitSubmoduleVersionInfo}; + +/// Gets the version information for the boto3 submodule. +/// +/// # Returns +/// +/// Returns the Git submodule metadata for boto3, including commit hash and version information. +/// +/// # Errors +/// +/// Returns an error if the boto3 version information cannot be retrieved. +pub fn get_boto3_version_info() -> Result { + GitSubmoduleVersionInfo::get_boto3_version_info() +} + +/// Gets the version information for the botocore submodule. +/// +/// # Returns +/// +/// Returns the Git submodule metadata for botocore, including commit hash and version information. +/// +/// # Errors +/// +/// Returns an error if the botocore version information cannot be retrieved. +pub fn get_botocore_version_info() -> Result { + GitSubmoduleVersionInfo::get_botocore_version_info() +} diff --git a/iam-policy-autopilot-policy-generation/src/api/mod.rs b/iam-policy-autopilot-policy-generation/src/api/mod.rs index 2bc4be8..8d67d9a 100644 --- a/iam-policy-autopilot-policy-generation/src/api/mod.rs +++ b/iam-policy-autopilot-policy-generation/src/api/mod.rs @@ -2,7 +2,9 @@ mod extract_sdk_calls; mod generate_policies; +mod get_submodule_version; pub use extract_sdk_calls::extract_sdk_calls; pub use generate_policies::generate_policies; +pub use get_submodule_version::{get_boto3_version_info, get_botocore_version_info}; mod common; pub mod model; diff --git a/iam-policy-autopilot-policy-generation/src/api/model.rs b/iam-policy-autopilot-policy-generation/src/api/model.rs index ff40859..77c9bb0 100644 --- a/iam-policy-autopilot-policy-generation/src/api/model.rs +++ b/iam-policy-autopilot-policy-generation/src/api/model.rs @@ -1,6 +1,8 @@ //! Defined model for API use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + /// Configuration for generate_policies Api #[derive(Debug, Clone)] pub struct GeneratePolicyConfig { @@ -49,6 +51,8 @@ pub struct AwsContext { pub account: String, } +include!("../shared_submodule_model.rs"); + impl AwsContext { /// Creates a new AwsContext with the partition automatically derived from the region. /// diff --git a/iam-policy-autopilot-policy-generation/src/embedded_data.rs b/iam-policy-autopilot-policy-generation/src/embedded_data.rs index 0670ffd..bd9ddf1 100644 --- a/iam-policy-autopilot-policy-generation/src/embedded_data.rs +++ b/iam-policy-autopilot-policy-generation/src/embedded_data.rs @@ -8,6 +8,7 @@ use std::borrow::Cow; use std::collections::HashMap; +use crate::api::model::GitSubmoduleMetadata; use crate::errors::{ExtractorError, Result}; use crate::extraction::sdk_model::SdkServiceDefinition; use rust_embed::RustEmbed; @@ -31,6 +32,11 @@ struct BotocoreRaw; #[include = "*.json"] struct Boto3ResourcesRaw; +#[derive(RustEmbed)] +#[folder = "target/submodule-version-info"] +#[include = "*.json"] +struct GitSubmoduleVersionInfoRaw; + /// Embedded boto3 utilities mapping /// /// This struct provides access to the boto3 utilities mapping configuration @@ -304,6 +310,38 @@ impl BotocoreData { } } +/// Embedded submodule version data manager +/// +/// Provides access to git submodule information, compiled during build.rs +pub(crate) struct GitSubmoduleVersionInfo; + +impl GitSubmoduleVersionInfo { + pub(crate) fn get_boto3_version_info() -> Result { + let boto3_file = GitSubmoduleVersionInfoRaw::get("boto3_version.json") + .expect("boto3 version metadata file not found"); + + serde_json::from_slice(&boto3_file.data).map_err(|e| { + ExtractorError::sdk_processing_with_source( + "reading boto3_version.json", + "Failed to parse boto3 metadata file", + e, + ) + }) + } + pub(crate) fn get_botocore_version_info() -> Result { + let botocore_file = GitSubmoduleVersionInfoRaw::get("botocore_version.json") + .expect("botocore version metadata file not found"); + + serde_json::from_slice(&botocore_file.data).map_err(|e| { + ExtractorError::sdk_processing_with_source( + "reading botocore_version.json", + "Failed to parse botocore_version metadata file", + e, + ) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -512,4 +550,15 @@ mod tests { let result = BotocoreData::get_service_definition("", ""); assert!(result.is_err()); } + + #[test] + fn test_get_boto3_version_info_happy_path() { + let result = GitSubmoduleVersionInfo::get_boto3_version_info(); + assert!(result.is_ok()); + } + + fn test_get_botocore_version_info_happy_path() { + let result = GitSubmoduleVersionInfo::get_botocore_version_info(); + assert!(result.is_ok()); + } } diff --git a/iam-policy-autopilot-policy-generation/src/shared_submodule_model.rs b/iam-policy-autopilot-policy-generation/src/shared_submodule_model.rs new file mode 100644 index 0000000..9b1acf5 --- /dev/null +++ b/iam-policy-autopilot-policy-generation/src/shared_submodule_model.rs @@ -0,0 +1,14 @@ +/// Exposes git version and commit hash for boto3 and botocore +/// The struct defined here is used in both build.rs and model.rs. +/// To share this struct in both the library and the build step, we define it here, +/// and use include!(...) to include it in both uses. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct GitSubmoduleMetadata { + /// the commit of boto3/botocore, returned on calls to iam-policy-autopilot --version --verbose + pub git_commit_hash: String, + /// the git tag of boto3/botocore, returned on calls to iam-policy-autopilot --version --verbose + pub git_tag: Option, + /// the sha hash of boto3/botocore simplified models, returned on calls to iam-policy-autopilot --version --verbose + pub data_hash: String, +} \ No newline at end of file