diff --git a/Cargo.lock b/Cargo.lock index 949029ff..7226e3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "anyhow", "assert_cmd", "assert_fs", + "cargo-lock", "cargo_metadata", "clap", "cyclonedx-bom", @@ -163,6 +164,18 @@ dependencies = [ "validator", ] +[[package]] +name = "cargo-lock" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11c675378efb449ed3ce8de78d75d0d80542fc98487c26aba28eb3b82feac72" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + [[package]] name = "cargo-platform" version = "0.1.4" @@ -323,6 +336,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.6" @@ -407,6 +426,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "heck" version = "0.4.1" @@ -458,6 +483,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "insta" version = "1.34.0" @@ -834,6 +869,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "similar" version = "2.3.0" @@ -980,6 +1024,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicase" version = "2.7.0" @@ -1245,6 +1323,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "xml-rs" version = "0.8.19" diff --git a/cargo-cyclonedx/Cargo.toml b/cargo-cyclonedx/Cargo.toml index 14f0d67b..28ce76ad 100644 --- a/cargo-cyclonedx/Cargo.toml +++ b/cargo-cyclonedx/Cargo.toml @@ -22,6 +22,7 @@ lto = "thin" [dependencies] anyhow = "1.0.75" +cargo-lock = "9.0.0" cargo_metadata = "0.18.1" clap = { version = "4.4.11", features = ["derive"] } cyclonedx-bom = { version = "0.4.3", path = "../cyclonedx-bom" } diff --git a/cargo-cyclonedx/src/generator.rs b/cargo-cyclonedx/src/generator.rs index 8ba2c862..7e275245 100644 --- a/cargo-cyclonedx/src/generator.rs +++ b/cargo-cyclonedx/src/generator.rs @@ -31,6 +31,8 @@ use cargo_metadata::NodeDep; use cargo_metadata::Package; use cargo_metadata::PackageId; +use cargo_lock::package::Checksum; +use cargo_lock::Lockfile; use cargo_metadata::camino::Utf8PathBuf; use cyclonedx_bom::external_models::normalized_string::NormalizedString; use cyclonedx_bom::external_models::spdx::SpdxExpression; @@ -54,6 +56,7 @@ use regex::Regex; use log::Level; use std::collections::BTreeMap; +use std::collections::HashMap; use std::convert::TryFrom; use std::fs::File; use std::io::BufWriter; @@ -70,6 +73,7 @@ type ResolveMap = BTreeMap; pub struct SbomGenerator { config: SbomConfig, workspace_root: Utf8PathBuf, + crate_hashes: HashMap, } impl SbomGenerator { @@ -93,12 +97,6 @@ impl SbomGenerator { top_level_dependencies(member, &packages, &resolve) }; - let generator = SbomGenerator { - config: config.clone(), - workspace_root: meta.workspace_root.to_owned(), - }; - let bom = generator.create_bom(member, &dependencies, &pruned_resolve)?; - // Figure out the types of the various produced artifacts. // This is additional information on top of the SBOM structure // that is used to implement emitting a separate SBOM for each binary or artifact. @@ -107,9 +105,33 @@ impl SbomGenerator { .map(|tgt| tgt.kind.clone()) .collect(); + let manifest_path = packages[member].manifest_path.clone().into_std_path_buf(); + + let mut crate_hashes = HashMap::new(); + match locate_cargo_lock(&manifest_path) { + Ok(path) => match Lockfile::load(path) { + Ok(lockfile_contents) => crate_hashes = package_hashes(&lockfile_contents), + Err(err) => log::warn!( + "Failed to parse `Cargo.lock`: {err}\n\ + Hashes will not be included in the SBOM." + ), + }, + Err(err) => log::warn!( + "Failed to locate `Cargo.lock`: {err}\n\ + Hashes will not be included in the SBOM." + ), + } + + let generator = SbomGenerator { + config: config.clone(), + workspace_root: meta.workspace_root.to_owned(), + crate_hashes, + }; + let bom = generator.create_bom(member, &dependencies, &pruned_resolve)?; + let generated = GeneratedSbom { bom, - manifest_path: packages[member].manifest_path.clone().into_std_path_buf(), + manifest_path, package_name: packages[member].name.clone(), sbom_config: generator.config, target_kinds, @@ -170,6 +192,7 @@ impl SbomGenerator { component.scope = Some(Scope::Required); component.external_references = Self::get_external_references(package); component.licenses = self.get_licenses(package); + component.hashes = self.get_hashes(package); component.description = package .description @@ -404,6 +427,24 @@ impl SbomGenerator { Some(Licenses(licenses)) } + fn get_hashes(&self, package: &Package) -> Option { + match self.crate_hashes.get(&package.id) { + Some(hash) => Some(cyclonedx_bom::models::hash::Hashes(vec![to_bom_hash(hash)])), + None => { + // Log level is set to debug because this is perfectly normal: + // First, only Rust 1.77 and later has `cargo metadata` output pkgid format, + // so anything prior to that won't match. + // Second, only packages coming from registries have a checksum associated with them, + // while local or git packages do not have a checksum and that too is normal. + log::debug!( + "Hash for package ID {} not found in Cargo.lock", + &package.id + ); + None + } + } + } + fn create_metadata(&self, package: &Package) -> Result { let authors = Self::create_authors(package); @@ -771,6 +812,62 @@ impl GeneratedSbom { } } +/// Locates the corresponding `Cargo.lock` file given the location of `Cargo.toml`. +/// This must be run **after** `cargo metadata` which will generate the `Cargo.lock` file +/// and make sure it's up to date. +fn locate_cargo_lock(manifest_path: &Path) -> Result { + let manifest_path = manifest_path.canonicalize()?; + let ancestors = manifest_path.as_path().ancestors(); + + for path in ancestors { + let potential_lockfile = path.join("Cargo.lock"); + if potential_lockfile.is_file() { + return Ok(potential_lockfile); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not find Cargo.lock in any parent directories", + )) +} + +/// Extracts all available package hashes from the provided `Cargo.lock` file +/// and collects them into a HashMap for fast and reasy lookup +fn package_hashes(lockfile: &Lockfile) -> HashMap { + let mut result = HashMap::new(); + for pkg in &lockfile.packages { + if let Some(hash) = pkg.checksum.as_ref() { + result.insert(cargo_metadata::PackageId { repr: pkgid(pkg) }, hash.clone()); + } + } + result +} + +/// Returns a Cargo unique identifier for a package. +/// See `cargo help pkgid` for more info. +fn pkgid(pkg: &cargo_lock::Package) -> String { + match pkg.source.as_ref() { + Some(source) => format!("{}#{}@{}", source, pkg.name, pkg.version), + None => format!("{}@{}", pkg.name, pkg.version), + } +} + +/// Converts a checksum from the `cargo-lock` crate format to `cyclonedx-bom` crate format +fn to_bom_hash(hash: &Checksum) -> cyclonedx_bom::models::hash::Hash { + use cyclonedx_bom::models::hash::{Hash, HashAlgorithm, HashValue}; + // use a match statement to get a compile-time error + // if/when more variants are added + match hash { + Checksum::Sha256(_) => { + Hash { + alg: HashAlgorithm::SHA256, + // {:x} means "format as lowercase hex" + content: HashValue(format!("{hash:x}")), + } + } + } +} + #[derive(Error, Debug)] pub enum SbomWriterError { #[error("I/O error")]