diff --git a/Cargo.lock b/Cargo.lock index 8d9d782..7e5ccc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tramiton-core", + "tramiton-repro", "urlencoding", "uuid", "walkdir", @@ -2101,7 +2102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3696,7 +3697,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4681,7 +4682,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5559,10 +5560,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6149,6 +6150,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tramiton-repro" +version = "0.4.0" +source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3" +dependencies = [ + "serde", + "sha2", + "tempfile", + "thiserror 1.0.69", + "toml", + "tramiton-core", + "walkdir", +] + [[package]] name = "tree-sitter" version = "0.24.7" @@ -6707,7 +6722,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 4637751..3a61ee8 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -15,6 +15,9 @@ compliance-dast = { path = "../compliance-dast" } # available to the onboarding classifier. NOTE: CI must be able to fetch this # private repo (see the git-auth step in .gitea/workflows/ci.yml). tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" } +# tramiton-repro's `libraries_from_inputs` turns a build plan's fetched inputs +# into the SBOM-friendly library list (analysis-based firmware SBOM, no build). +tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/compliance-agent/src/pipeline/firmware_sbom.rs b/compliance-agent/src/pipeline/firmware_sbom.rs new file mode 100644 index 0000000..7a8feae --- /dev/null +++ b/compliance-agent/src/pipeline/firmware_sbom.rs @@ -0,0 +1,81 @@ +//! Analysis-based firmware SBOM. +//! +//! Derives a Software Bill of Materials for a firmware / embedded target from +//! tramiton's build-plan analysis — the resolved external libraries and the +//! cross-toolchain — *without* running a reproducible build. It reuses the same +//! `tramiton_core::provider::analyze` pass classification runs, so an SBOM comes +//! out of the source tree with no binary upload and no build toolchain in the +//! agent image. +//! +//! A full reproducible-build SBOM (with artifact-level content hashes from a +//! sealed `tramiton.lock`) is a later, opt-in phase — it needs tramiton's nix +//! build backend available to the agent. + +use std::path::Path; + +use compliance_core::models::{SbomEntry, TargetType}; + +/// Whether analysis-based firmware SBOM applies to this target family. +pub fn is_firmware_target(target_type: TargetType) -> bool { + matches!( + target_type, + TargetType::FirmwareBareMetal | TargetType::FirmwareRtos | TargetType::EmbeddedLinuxYocto + ) +} + +/// Build SBOM entries for a firmware target by analyzing its source tree with +/// tramiton. Returns an empty vector when tramiton cannot form a build plan +/// (e.g. no recognizable embedded build system), so callers can treat "no +/// firmware SBOM" as simply an empty result. +pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec { + let p = path.to_path_buf(); + // `analyze` is CPU-bound source inspection — keep it off the async runtime. + let plan = match tokio::task::spawn_blocking(move || { + let repo = tramiton_core::Repo::new(&p); + tramiton_core::provider::analyze(&repo) + }) + .await + { + Ok(Ok(plan)) => plan, + Ok(Err(e)) => { + tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed"); + return Vec::new(); + } + Err(e) => { + tracing::warn!(repo_id, error = %e, "Firmware SBOM: analyze task join error"); + return Vec::new(); + } + }; + let Some(bp) = plan else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + + // The cross-toolchain, recorded as a component so the SBOM captures how the + // firmware is built (arm-none-eabi-gcc, zephyr-sdk, ...). + if let Some(id) = bp.toolchain.id.clone() { + let version = bp.toolchain.version.clone().unwrap_or_default(); + entries.push(SbomEntry::new( + repo_id.to_string(), + id, + version, + "toolchain".to_string(), + )); + } + + // Resolved external libraries — the SBOM-friendly view of the plan's fetched + // build inputs (name @ revision, with the upstream source when known). + for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) { + let mut entry = SbomEntry::new( + repo_id.to_string(), + lib.name, + lib.revision, + "tramiton".to_string(), + ); + entry.purl = lib.source; + entries.push(entry); + } + + entries +} diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs index 70d91b8..c157fde 100644 --- a/compliance-agent/src/pipeline/mod.rs +++ b/compliance-agent/src/pipeline/mod.rs @@ -1,6 +1,7 @@ pub mod code_review; pub mod cve; pub mod dedup; +pub mod firmware_sbom; pub mod git; pub mod gitleaks; mod graph_build; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index d7eed7e..4135568 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -601,6 +601,47 @@ impl PipelineOrchestrator { tracing::warn!(target_id, error = %e, "Unified pipeline: classification failed") } } + + // Analysis-based firmware SBOM: for embedded targets, derive components + // (resolved libraries + cross-toolchain) from tramiton's build-plan + // analysis over the already-ingested source — no build, no binary + // upload. Best-effort; empty when no build plan forms. + if crate::pipeline::firmware_sbom::is_firmware_target(target.target_type) { + if let Some(code) = target.code_artifact() { + if let Some(path) = working_paths.get(&code.id) { + let entries = + crate::pipeline::firmware_sbom::firmware_sbom_entries(path, target_id) + .await; + if !entries.is_empty() { + let _ = self + .db + .sbom_entries() + .delete_many(doc! { "repo_id": target_id }) + .await; + for entry in &entries { + let filter = doc! { + "repo_id": &entry.repo_id, + "name": &entry.name, + "version": &entry.version, + }; + if let Ok(d) = mongodb::bson::to_document(entry) { + let _ = self + .db + .sbom_entries() + .update_one(filter, doc! { "$set": d }) + .upsert(true) + .await; + } + } + tracing::info!( + target_id, + count = entries.len(), + "Firmware SBOM: stored components from tramiton analysis" + ); + } + } + } + } } /// If the target has a `LiveUrl` artifact and DAST is planned, provision a