feat(pipeline): firmware SBOM via tramiton reproducible build (phase 2) (#157)
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
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
This commit was merged in pull request #157.
This commit is contained in:
Generated
+28
-5
@@ -694,6 +694,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tramiton-core",
|
"tramiton-core",
|
||||||
"tramiton-repro",
|
"tramiton-repro",
|
||||||
|
"tramiton-sbom",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -2102,7 +2103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3768,6 +3769,15 @@ 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"
|
||||||
@@ -4682,7 +4692,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5560,10 +5570,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6164,6 +6174,19 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tramiton-sbom"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
|
||||||
|
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"
|
||||||
@@ -6722,7 +6745,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ 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
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ compliance-dast = { path = "../compliance-dast" }
|
|||||||
# 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.0" }
|
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
|
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that
|
||||||
# into the SBOM-friendly library list (analysis-based firmware SBOM, no build).
|
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
|
||||||
tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.0" }
|
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.0" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
//! Analysis-based firmware SBOM.
|
//! Firmware SBOM via tramiton.
|
||||||
//!
|
//!
|
||||||
//! Derives a Software Bill of Materials for a firmware / embedded target from
|
//! Phase 2 (full, the default): drive a **reproducible build** with tramiton's
|
||||||
//! tramiton's build-plan analysis — the resolved external libraries and the
|
//! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries
|
||||||
//! cross-toolchain — *without* running a reproducible build. It reuses the same
|
//! are pinned and whose firmware artifact carries a content hash — then render
|
||||||
//! `tramiton_core::provider::analyze` pass classification runs, so an SBOM comes
|
//! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is
|
||||||
//! out of the source tree with no binary upload and no build toolchain in the
|
//! the complete bill of materials (toolchain + every fetched library + the
|
||||||
//! agent image.
|
//! firmware image), the same one `tramiton sbom` produces.
|
||||||
//!
|
//!
|
||||||
//! A full reproducible-build SBOM (with artifact-level content hashes from a
|
//! Phase 1 fallback (analysis-only): when no nix backend is available or the
|
||||||
//! sealed `tramiton.lock`) is a later, opt-in phase — it needs tramiton's nix
|
//! build fails, fall back to the resolvable libraries + toolchain from the build
|
||||||
//! build backend available to the agent.
|
//! 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 std::path::Path;
|
||||||
|
|
||||||
use compliance_core::models::{SbomEntry, TargetType};
|
use compliance_core::models::{SbomEntry, TargetType};
|
||||||
|
use tramiton_repro::ReproBackend;
|
||||||
|
use tramiton_sbom::ComponentKind;
|
||||||
|
|
||||||
/// Whether analysis-based firmware SBOM applies to this target family.
|
/// Whether 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,
|
||||||
@@ -23,37 +26,109 @@ pub fn is_firmware_target(target_type: TargetType) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build SBOM entries for a firmware target by analyzing its source tree with
|
/// Build SBOM entries for a firmware target from its source tree. Prefers a full
|
||||||
/// tramiton. Returns an empty vector when tramiton cannot form a build plan
|
/// reproducible build (sealed lock); falls back to analysis-only. Returns an
|
||||||
/// (e.g. no recognizable embedded build system), so callers can treat "no
|
/// empty vector when tramiton cannot even form a build plan.
|
||||||
/// 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();
|
||||||
// `analyze` is CPU-bound source inspection — keep it off the async runtime.
|
let repo = repo_id.to_string();
|
||||||
let plan = match tokio::task::spawn_blocking(move || {
|
// The whole analyze → seal → build → render sequence is blocking (it shells
|
||||||
let repo = tramiton_core::Repo::new(&p);
|
// out to nix), so keep it off the async runtime. Bound it: a firmware build
|
||||||
tramiton_core::provider::analyze(&repo)
|
// that hangs must not wedge the scan (the orphaned task is abandoned).
|
||||||
})
|
let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo));
|
||||||
.await
|
match tokio::time::timeout(std::time::Duration::from_secs(900), handle).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();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
Err(e) => {
|
||||||
tracing::warn!(repo_id, error = %e, "Firmware SBOM: analyze task join error");
|
tracing::warn!(repo_id, error = %e, "Firmware SBOM: reproducible build failed; falling back to analysis-only")
|
||||||
return Vec::new();
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
repo_id,
|
||||||
|
"Firmware SBOM: no nix backend available; analysis-only SBOM"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let Some(bp) = plan else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// The cross-toolchain, recorded as a component so the SBOM captures how the
|
fn component_to_entry(c: &tramiton_sbom::Component, repo_id: &str) -> SbomEntry {
|
||||||
// firmware is built (arm-none-eabi-gcc, zephyr-sdk, ...).
|
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() {
|
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(
|
||||||
@@ -63,19 +138,15 @@ pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry>
|
|||||||
"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,
|
||||||
"tramiton".to_string(),
|
"library".to_string(),
|
||||||
);
|
);
|
||||||
entry.purl = lib.source;
|
entry.purl = lib.source;
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user