Skip to content

Commit 613e7dc

Browse files
authored
Merge pull request #3 from Point72/tkp/sbom
add cyclonedx sbom from lock or release
2 parents c1630ed + 825c3f1 commit 613e7dc

4 files changed

Lines changed: 295 additions & 1 deletion

File tree

rust/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/src/cli.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ enum Command {
8888
Compose(ComposeArgs),
8989
/// Report the licenses of a lock's packages and flag denied ones.
9090
License(LicenseArgs),
91+
/// Generate a CycloneDX SBOM (JSON) from a lock or a published environment.
92+
Sbom(SbomArgs),
9193
/// Re-derive a lock's content address to verify its integrity.
9294
Verify(VerifyArgs),
9395
/// Build a container image (SIF or OCI) from a published environment.
@@ -282,6 +284,34 @@ struct ManifestArgs {
282284
output: Option<PathBuf>,
283285
}
284286

287+
#[derive(Args)]
288+
struct SbomArgs {
289+
/// Generate from a local lock file (no registry needed).
290+
#[arg(long, conflicts_with_all = ["env", "registry"])]
291+
lock: Option<PathBuf>,
292+
/// Environment name to resolve from a registry (with `--registry`).
293+
#[arg(long, requires = "registry")]
294+
env: Option<String>,
295+
/// Registry root URL to resolve from (with `--env`).
296+
#[arg(long, requires = "env")]
297+
registry: Option<String>,
298+
/// Target platform (defaults to the current platform).
299+
#[arg(long)]
300+
platform: Option<String>,
301+
/// Python axis value, if the environment fans out over python.
302+
#[arg(long)]
303+
python: Option<String>,
304+
/// Variant axis value (e.g. `cpu`/`gpu`), if any.
305+
#[arg(long)]
306+
variant: Option<String>,
307+
/// Version label to resolve.
308+
#[arg(long, default_value = "latest")]
309+
label: String,
310+
/// File to write the SBOM to (defaults to stdout).
311+
#[arg(short, long)]
312+
output: Option<PathBuf>,
313+
}
314+
285315
#[derive(Args)]
286316
struct VerifyArgs {
287317
/// Verify a local lock file's content address (optionally against `--expect`).
@@ -768,6 +798,7 @@ async fn run_command(command: Command) -> CliResult {
768798
Command::DiffVersions(args) => diff_versions(args),
769799
Command::Compose(args) => compose(args).await,
770800
Command::License(args) => license(args),
801+
Command::Sbom(args) => sbom(args),
771802
Command::Verify(args) => verify(args),
772803
Command::Image(ImageCommand::Build(args)) => image_build(args).await,
773804
Command::Cache(CacheCommand::Clean { all }) => cache_clean(all),
@@ -970,6 +1001,43 @@ fn manifest(args: ManifestArgs) -> CliResult {
9701001
Ok(())
9711002
}
9721003

1004+
fn sbom(args: SbomArgs) -> CliResult {
1005+
// Load the lock bytes from either source: a local file, or a registry
1006+
// release resolved by coordinates + label.
1007+
let lock_bytes = if let Some(lock_path) = &args.lock {
1008+
std::fs::read(lock_path)?
1009+
} else if let (Some(env), Some(registry_url)) = (&args.env, &args.registry) {
1010+
let registry = Registry::new(SpecStore::new(), registry_url.clone());
1011+
let platform = args
1012+
.platform
1013+
.clone()
1014+
.unwrap_or_else(|| Platform::current().to_string());
1015+
let mut coords = Coordinates::new(env.clone(), platform);
1016+
if let Some(py) = &args.python {
1017+
coords = coords.with_python(py.clone());
1018+
}
1019+
if let Some(v) = &args.variant {
1020+
coords = coords.with_variant(v.clone());
1021+
}
1022+
let label = Label::parse(&args.label);
1023+
registry.pull(&coords, &label)?
1024+
} else {
1025+
return Err("pass --lock <file>, or --env <name> --registry <url>".into());
1026+
};
1027+
1028+
let lock = install::parse_lock(&lock_bytes)?;
1029+
let json = crate::sbom::to_cyclonedx(&lock)?;
1030+
1031+
match &args.output {
1032+
Some(path) => {
1033+
std::fs::write(path, json.as_bytes())?;
1034+
eprintln!("wrote SBOM → {}", path.display());
1035+
}
1036+
None => println!("{json}"),
1037+
}
1038+
Ok(())
1039+
}
1040+
9731041
fn license(args: LicenseArgs) -> CliResult {
9741042
// Load the lock bytes from either source: a local file, or a registry
9751043
// release resolved by coordinates + label.

rust/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub mod producer;
3434
pub mod project;
3535
pub mod registry;
3636
pub mod run;
37+
pub mod sbom;
3738
pub mod selector;
3839
pub mod solve;
3940

rust/src/sbom.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//! Generate a [CycloneDX](https://cyclonedx.org/) 1.5 SBOM (JSON) from a solved
2+
//! lock.
3+
//!
4+
//! A lock already pins every conda package with its exact version, build, and
5+
//! content hash — a software bill of materials is a direct projection of that.
6+
//! The document is **deterministic**: components are keyed by package URL and
7+
//! emitted in sorted order, and no generation timestamp is included, so the same
8+
//! lock always yields byte-identical SBOM output (itself a useful property to
9+
//! attest).
10+
11+
use std::collections::BTreeMap;
12+
13+
use rattler_lock::LockFile;
14+
use serde::Serialize;
15+
16+
/// Errors raised while generating an SBOM.
17+
#[derive(Debug)]
18+
pub enum SbomError {
19+
/// Reading conda records out of the lock failed.
20+
Lock(String),
21+
/// Serialising the SBOM document to JSON failed.
22+
Serialize(serde_json::Error),
23+
}
24+
25+
impl std::fmt::Display for SbomError {
26+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27+
match self {
28+
SbomError::Lock(msg) => write!(f, "{msg}"),
29+
SbomError::Serialize(e) => write!(f, "serialising SBOM failed: {e}"),
30+
}
31+
}
32+
}
33+
34+
impl std::error::Error for SbomError {}
35+
36+
#[derive(Serialize)]
37+
struct Bom {
38+
#[serde(rename = "bomFormat")]
39+
bom_format: &'static str,
40+
#[serde(rename = "specVersion")]
41+
spec_version: &'static str,
42+
version: u32,
43+
metadata: Metadata,
44+
components: Vec<Component>,
45+
}
46+
47+
#[derive(Serialize)]
48+
struct Metadata {
49+
tools: Vec<Tool>,
50+
}
51+
52+
#[derive(Serialize)]
53+
struct Tool {
54+
vendor: &'static str,
55+
name: &'static str,
56+
version: &'static str,
57+
}
58+
59+
#[derive(Serialize)]
60+
struct Component {
61+
#[serde(rename = "bom-ref")]
62+
bom_ref: String,
63+
#[serde(rename = "type")]
64+
kind: &'static str,
65+
name: String,
66+
version: String,
67+
purl: String,
68+
#[serde(skip_serializing_if = "Vec::is_empty")]
69+
hashes: Vec<Hash>,
70+
#[serde(skip_serializing_if = "Vec::is_empty")]
71+
licenses: Vec<LicenseChoice>,
72+
}
73+
74+
#[derive(Serialize)]
75+
struct Hash {
76+
alg: &'static str,
77+
content: String,
78+
}
79+
80+
#[derive(Serialize)]
81+
struct LicenseChoice {
82+
license: License,
83+
}
84+
85+
#[derive(Serialize)]
86+
struct License {
87+
name: String,
88+
}
89+
90+
/// Build a conda package URL (purl): `pkg:conda/<name>@<version>` with `build`
91+
/// and `subdir` qualifiers. conda names, versions, and builds use a purl-safe
92+
/// character set, so no percent-encoding is required.
93+
fn conda_purl(name: &str, version: &str, build: &str, subdir: &str) -> String {
94+
format!("pkg:conda/{name}@{version}?build={build}&subdir={subdir}")
95+
}
96+
97+
/// Render `lock` as a CycloneDX 1.5 JSON SBOM. Every distinct conda package
98+
/// across all environments and platforms in the lock becomes one component,
99+
/// deduplicated and sorted by package URL.
100+
pub fn to_cyclonedx(lock: &LockFile) -> Result<String, SbomError> {
101+
let mut components: BTreeMap<String, Component> = BTreeMap::new();
102+
103+
for (_env_name, env) in lock.environments() {
104+
for platform in env.platforms() {
105+
let records = env
106+
.conda_repodata_records(platform)
107+
.map_err(|e| SbomError::Lock(format!("converting lock records: {e}")))?
108+
.unwrap_or_default();
109+
for record in records {
110+
let pr = &record.package_record;
111+
let name = pr.name.as_normalized().to_string();
112+
let version = pr.version.as_str().to_string();
113+
let build = pr.build.clone();
114+
let subdir = pr.subdir.clone();
115+
let purl = conda_purl(&name, &version, &build, &subdir);
116+
if components.contains_key(&purl) {
117+
continue;
118+
}
119+
let hashes = pr
120+
.sha256
121+
.as_ref()
122+
.map(|digest| {
123+
vec![Hash {
124+
alg: "SHA-256",
125+
content: hex::encode(digest),
126+
}]
127+
})
128+
.unwrap_or_default();
129+
let licenses = pr
130+
.license
131+
.as_ref()
132+
.filter(|l| !l.is_empty())
133+
.map(|name| {
134+
vec![LicenseChoice {
135+
license: License { name: name.clone() },
136+
}]
137+
})
138+
.unwrap_or_default();
139+
components.insert(
140+
purl.clone(),
141+
Component {
142+
bom_ref: purl.clone(),
143+
kind: "library",
144+
name,
145+
version,
146+
purl,
147+
hashes,
148+
licenses,
149+
},
150+
);
151+
}
152+
}
153+
}
154+
155+
let bom = Bom {
156+
bom_format: "CycloneDX",
157+
spec_version: "1.5",
158+
version: 1,
159+
metadata: Metadata {
160+
tools: vec![Tool {
161+
vendor: "nepenthe",
162+
name: "nepenthe",
163+
version: env!("CARGO_PKG_VERSION"),
164+
}],
165+
},
166+
components: components.into_values().collect(),
167+
};
168+
169+
serde_json::to_string_pretty(&bom).map_err(SbomError::Serialize)
170+
}
171+
172+
#[cfg(test)]
173+
mod tests {
174+
use super::*;
175+
176+
#[test]
177+
fn conda_purl_carries_build_and_subdir() {
178+
assert_eq!(
179+
conda_purl("numpy", "2.1.0", "py311h0001_0", "linux-64"),
180+
"pkg:conda/numpy@2.1.0?build=py311h0001_0&subdir=linux-64"
181+
);
182+
}
183+
184+
/// A minimal real lock (one package) renders a valid, deterministic
185+
/// CycloneDX document with the package as a component.
186+
#[test]
187+
fn renders_cyclonedx_from_a_lock() {
188+
let yaml = r#"version: 6
189+
environments:
190+
default:
191+
channels:
192+
- url: https://conda.anaconda.org/conda-forge/
193+
packages:
194+
linux-64:
195+
- conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda
196+
packages:
197+
- conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda
198+
sha256: 0a8c9a0b0a0d0e0f0102030405060708090a0b0c0d0e0f101112131415161718
199+
md5: 9c12429eb8e07e7c5d36a8b8b0d0e0f0
200+
license: ISC
201+
size: 159003
202+
timestamp: 1725018903918
203+
"#;
204+
let lock = LockFile::from_str_with_base_directory(yaml, None).expect("valid lock");
205+
let json = to_cyclonedx(&lock).expect("renders");
206+
let doc: serde_json::Value = serde_json::from_str(&json).expect("valid json");
207+
208+
assert_eq!(doc["bomFormat"], "CycloneDX");
209+
assert_eq!(doc["specVersion"], "1.5");
210+
let components = doc["components"].as_array().expect("components array");
211+
assert_eq!(components.len(), 1);
212+
let c = &components[0];
213+
assert_eq!(c["name"], "ca-certificates");
214+
assert_eq!(c["type"], "library");
215+
assert_eq!(
216+
c["purl"],
217+
"pkg:conda/ca-certificates@2024.8.30?build=hbcca054_0&subdir=linux-64"
218+
);
219+
assert_eq!(c["licenses"][0]["license"]["name"], "ISC");
220+
assert_eq!(c["hashes"][0]["alg"], "SHA-256");
221+
222+
// Deterministic: same lock → byte-identical output.
223+
assert_eq!(json, to_cyclonedx(&lock).expect("renders again"));
224+
}
225+
}

0 commit comments

Comments
 (0)