CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 4m53s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m45s
153 lines
5.8 KiB
Rust
153 lines
5.8 KiB
Rust
//! Firmware SBOM via tramiton.
|
|
//!
|
|
//! Phase 2 (full, the default): drive a **reproducible build** with tramiton's
|
|
//! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries
|
|
//! are pinned and whose firmware artifact carries a content hash — then render
|
|
//! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is
|
|
//! the complete bill of materials (toolchain + every fetched library + the
|
|
//! firmware image), the same one `tramiton sbom` produces.
|
|
//!
|
|
//! Phase 1 fallback (analysis-only): when no nix backend is available or the
|
|
//! build fails, fall back to the resolvable libraries + toolchain from the build
|
|
//! plan alone (no build). A scan therefore always yields *something*, and a nix
|
|
//! that can't run in the deployment never breaks a scan.
|
|
|
|
use std::path::Path;
|
|
|
|
use compliance_core::models::{SbomEntry, TargetType};
|
|
use tramiton_repro::ReproBackend;
|
|
use tramiton_sbom::ComponentKind;
|
|
|
|
/// Whether 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 from its source tree. Prefers a full
|
|
/// reproducible build (sealed lock); falls back to analysis-only. Returns an
|
|
/// empty vector when tramiton cannot even form a build plan.
|
|
pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
|
let p = path.to_path_buf();
|
|
let repo = repo_id.to_string();
|
|
// The whole analyze → seal → build → render sequence is blocking (it shells
|
|
// out to nix), so keep it off the async runtime. Bound it: a firmware build
|
|
// that hangs must not wedge the scan (the orphaned task is abandoned).
|
|
let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo));
|
|
match tokio::time::timeout(std::time::Duration::from_secs(900), handle).await {
|
|
Ok(Ok(entries)) => entries,
|
|
Ok(Err(e)) => {
|
|
tracing::warn!(repo_id, error = %e, "Firmware SBOM: task join error");
|
|
Vec::new()
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(repo_id, "Firmware SBOM: build exceeded 15m; skipping");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_sbom_blocking(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
|
let repo = tramiton_core::Repo::new(path);
|
|
let plan = match tramiton_core::provider::analyze(&repo) {
|
|
Ok(Some(bp)) => bp,
|
|
Ok(None) => return Vec::new(),
|
|
Err(e) => {
|
|
tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed");
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
// Phase 2: reproducible build → sealed lock → complete SBOM.
|
|
if let Some(backend) = tramiton_repro::NixBackend::detect() {
|
|
match tramiton_repro::seal_and_build(&backend, &plan, path) {
|
|
Ok(lock) => {
|
|
let mut sbom = tramiton_sbom::Sbom::from_lock(&lock, repo_id);
|
|
// Deep binary SCA of any pre-compiled inputs in the tree.
|
|
sbom.components.extend(tramiton_sbom::binary::scan(path));
|
|
let entries = sbom_to_entries(&sbom, repo_id);
|
|
tracing::info!(
|
|
repo_id,
|
|
backend = backend.name(),
|
|
count = entries.len(),
|
|
"Firmware SBOM: sealed reproducible build"
|
|
);
|
|
return entries;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(repo_id, error = %e, "Firmware SBOM: reproducible build failed; falling back to analysis-only")
|
|
}
|
|
}
|
|
} else {
|
|
tracing::info!(
|
|
repo_id,
|
|
"Firmware SBOM: no nix backend available; analysis-only SBOM"
|
|
);
|
|
}
|
|
|
|
// Phase 1 fallback: analysis-only (toolchain + resolvable libraries).
|
|
analysis_entries(&plan, repo_id)
|
|
}
|
|
|
|
/// Map a rendered [`tramiton_sbom::Sbom`] (primary firmware + components) into
|
|
/// our [`SbomEntry`] rows. Source-file (`File`) components are dropped — they are
|
|
/// build inputs, not a dependency inventory.
|
|
fn sbom_to_entries(sbom: &tramiton_sbom::Sbom, repo_id: &str) -> Vec<SbomEntry> {
|
|
let mut entries = Vec::new();
|
|
if let Some(primary) = &sbom.primary {
|
|
entries.push(component_to_entry(primary, repo_id));
|
|
}
|
|
for c in &sbom.components {
|
|
if matches!(c.kind, ComponentKind::File) {
|
|
continue;
|
|
}
|
|
entries.push(component_to_entry(c, repo_id));
|
|
}
|
|
entries
|
|
}
|
|
|
|
fn component_to_entry(c: &tramiton_sbom::Component, repo_id: &str) -> SbomEntry {
|
|
let manager = match c.kind {
|
|
ComponentKind::Firmware => "firmware",
|
|
ComponentKind::Library => "library",
|
|
ComponentKind::Toolchain => "toolchain",
|
|
ComponentKind::File => "file",
|
|
};
|
|
let mut entry = SbomEntry::new(
|
|
repo_id.to_string(),
|
|
c.name.clone(),
|
|
c.version.clone().unwrap_or_default(),
|
|
manager.to_string(),
|
|
);
|
|
entry.purl = c.source.clone();
|
|
entry
|
|
}
|
|
|
|
/// Analysis-only components from the build plan: the cross-toolchain plus the
|
|
/// resolvable fetched libraries, without a build.
|
|
fn analysis_entries(bp: &tramiton_core::BuildPlan, repo_id: &str) -> Vec<SbomEntry> {
|
|
let mut entries = Vec::new();
|
|
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(),
|
|
));
|
|
}
|
|
for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) {
|
|
let mut entry = SbomEntry::new(
|
|
repo_id.to_string(),
|
|
lib.name,
|
|
lib.revision,
|
|
"library".to_string(),
|
|
);
|
|
entry.purl = lib.source;
|
|
entries.push(entry);
|
|
}
|
|
entries
|
|
}
|