Compare commits

..
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 9b47b6419a feat(pipeline): analysis-based firmware SBOM from tramiton
CI / Check (pull_request) Successful in 6m57s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Firmware/embedded targets got no SBOM: syft finds no package manifest in a
C/CMake/west source tree, and the real firmware bill-of-materials lives in the
build, not a lockfile. Derive it from tramiton's build-plan analysis instead.

New `pipeline::firmware_sbom`: runs `tramiton_core::provider::analyze` over the
already-ingested source (reusing the classification pass — no second clone) and
maps the plan's resolved libraries (`libraries_from_inputs`) + cross-toolchain
into `SbomEntry` records keyed by the target id. No binary upload, no nix build
toolchain in the agent image. Empty when tramiton can't form a build plan.

Wired into `classify_and_store` for firmware target types; the entries land in
`sbom_entries` so they show on the SBOM page and feed CVE matching like any
other SBOM. A full reproducible-build SBOM (sealed tramiton.lock with
artifact-level hashes) is the planned opt-in second phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:23:55 +02:00
4 changed files with 47 additions and 157 deletions
Generated
+6 -30
View File
@@ -694,7 +694,6 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"tramiton-core", "tramiton-core",
"tramiton-repro", "tramiton-repro",
"tramiton-sbom",
"urlencoding", "urlencoding",
"uuid", "uuid",
"walkdir", "walkdir",
@@ -3698,7 +3697,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -3769,15 +3768,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "octocrab" name = "octocrab"
version = "0.44.1" version = "0.44.1"
@@ -4679,7 +4669,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6150,8 +6140,8 @@ dependencies = [
[[package]] [[package]]
name = "tramiton-core" name = "tramiton-core"
version = "0.4.1" version = "0.4.0"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba" source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
dependencies = [ dependencies = [
"serde", "serde",
"tempfile", "tempfile",
@@ -6162,11 +6152,10 @@ dependencies = [
[[package]] [[package]]
name = "tramiton-repro" name = "tramiton-repro"
version = "0.4.1" version = "0.4.0"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba" source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -6175,19 +6164,6 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tramiton-sbom"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"object",
"serde",
"serde_json",
"sha2",
"tramiton-core",
"tramiton-repro",
]
[[package]] [[package]]
name = "tree-sitter" name = "tree-sitter"
version = "0.24.7" version = "0.24.7"
-13
View File
@@ -40,19 +40,6 @@ RUN pip3 install --break-system-packages semgrep
# Install ruff for Python linting # Install ruff for Python linting
RUN pip3 install --break-system-packages ruff RUN pip3 install --break-system-packages ruff
# Install nix-portable (rootless nix) so the firmware-SBOM pipeline can drive a
# tramiton reproducible build (NixBackend). Best-effort: if the download fails,
# the agent falls back to analysis-only firmware SBOMs (never breaks a scan).
# The nix store lives under NP_LOCATION — mount a PERSISTENT volume there in the
# deployment, else every firmware scan re-fetches nixpkgs + cross toolchains.
ARG NIX_PORTABLE_VERSION=v012
RUN curl -fsSL -o /usr/local/bin/nix-portable \
"https://github.com/DavHau/nix-portable/releases/download/${NIX_PORTABLE_VERSION}/nix-portable-x86_64" \
&& chmod +x /usr/local/bin/nix-portable \
|| { rm -f /usr/local/bin/nix-portable; echo "WARN: nix-portable install skipped; firmware SBOM uses analysis-only fallback"; }
ENV NP_LOCATION=/data/compliance-scanner
RUN mkdir -p /data/compliance-scanner
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
# Copy documentation for the help chat assistant # Copy documentation for the help chat assistant
+4 -6
View File
@@ -14,12 +14,10 @@ compliance-dast = { path = "../compliance-dast" }
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is # Same-company IP, used directly (not via CLI) so the whole tramiton suite is
# available to the onboarding classifier. NOTE: CI must be able to fetch this # 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). # 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.1" } tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" }
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that # tramiton-repro's `libraries_from_inputs` turns a build plan's fetched inputs
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback. # 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.1" } tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" }
# tramiton-sbom renders the bill of materials from a sealed lock (+ binary SCA).
tramiton-sbom = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+37 -108
View File
@@ -1,24 +1,21 @@
//! Firmware SBOM via tramiton. //! Analysis-based firmware SBOM.
//! //!
//! Phase 2 (full, the default): drive a **reproducible build** with tramiton's //! Derives a Software Bill of Materials for a firmware / embedded target from
//! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries //! tramiton's build-plan analysis — the resolved external libraries and the
//! are pinned and whose firmware artifact carries a content hash — then render //! cross-toolchain — *without* running a reproducible build. It reuses the same
//! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is //! `tramiton_core::provider::analyze` pass classification runs, so an SBOM comes
//! the complete bill of materials (toolchain + every fetched library + the //! out of the source tree with no binary upload and no build toolchain in the
//! firmware image), the same one `tramiton sbom` produces. //! agent image.
//! //!
//! Phase 1 fallback (analysis-only): when no nix backend is available or the //! A full reproducible-build SBOM (with artifact-level content hashes from a
//! build fails, fall back to the resolvable libraries + toolchain from the build //! sealed `tramiton.lock`) is a later, opt-in phase — it needs tramiton's nix
//! plan alone (no build). A scan therefore always yields *something*, and a nix //! build backend available to the agent.
//! that can't run in the deployment never breaks a scan.
use std::path::Path; use std::path::Path;
use compliance_core::models::{SbomEntry, TargetType}; use compliance_core::models::{SbomEntry, TargetType};
use tramiton_repro::ReproBackend;
use tramiton_sbom::ComponentKind;
/// Whether firmware SBOM applies to this target family. /// Whether analysis-based firmware SBOM applies to this target family.
pub fn is_firmware_target(target_type: TargetType) -> bool { pub fn is_firmware_target(target_type: TargetType) -> bool {
matches!( matches!(
target_type, target_type,
@@ -26,109 +23,37 @@ pub fn is_firmware_target(target_type: TargetType) -> bool {
) )
} }
/// Build SBOM entries for a firmware target from its source tree. Prefers a full /// Build SBOM entries for a firmware target by analyzing its source tree with
/// reproducible build (sealed lock); falls back to analysis-only. Returns an /// tramiton. Returns an empty vector when tramiton cannot form a build plan
/// empty vector when tramiton cannot even 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> { pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
let p = path.to_path_buf(); let p = path.to_path_buf();
let repo = repo_id.to_string(); // `analyze` is CPU-bound source inspection — keep it off the async runtime.
// The whole analyze → seal → build → render sequence is blocking (it shells let plan = match tokio::task::spawn_blocking(move || {
// out to nix), so keep it off the async runtime. Bound it: a firmware build let repo = tramiton_core::Repo::new(&p);
// that hangs must not wedge the scan (the orphaned task is abandoned). tramiton_core::provider::analyze(&repo)
let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo)); })
match tokio::time::timeout(std::time::Duration::from_secs(900), handle).await { .await
Ok(Ok(entries)) => entries, {
Ok(Ok(plan)) => plan,
Ok(Err(e)) => { 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"); tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed");
return Vec::new(); 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();
}; };
// 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(); 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 { // The cross-toolchain, recorded as a component so the SBOM captures how the
let manager = match c.kind { // firmware is built (arm-none-eabi-gcc, zephyr-sdk, ...).
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() { if let Some(id) = bp.toolchain.id.clone() {
let version = bp.toolchain.version.clone().unwrap_or_default(); let version = bp.toolchain.version.clone().unwrap_or_default();
entries.push(SbomEntry::new( entries.push(SbomEntry::new(
@@ -138,15 +63,19 @@ fn analysis_entries(bp: &tramiton_core::BuildPlan, repo_id: &str) -> Vec<SbomEnt
"toolchain".to_string(), "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) { for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) {
let mut entry = SbomEntry::new( let mut entry = SbomEntry::new(
repo_id.to_string(), repo_id.to_string(),
lib.name, lib.name,
lib.revision, lib.revision,
"library".to_string(), "tramiton".to_string(),
); );
entry.purl = lib.source; entry.purl = lib.source;
entries.push(entry); entries.push(entry);
} }
entries entries
} }