feat(pipeline): analysis-based firmware SBOM from tramiton (#156)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m3s
CI / Deploy Dashboard (push) Successful in 3m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m14s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m3s
CI / Deploy Dashboard (push) Successful in 3m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m14s
This commit was merged in pull request #156.
This commit is contained in:
@@ -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<SbomEntry> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user