diff --git a/Cargo.lock b/Cargo.lock index 7e5ccc7..f421b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,7 @@ dependencies = [ "tracing-subscriber", "tramiton-core", "tramiton-repro", + "tramiton-sbom", "urlencoding", "uuid", "walkdir", @@ -2102,7 +2103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3768,6 +3769,15 @@ dependencies = [ "syn", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + [[package]] name = "octocrab" version = "0.44.1" @@ -4682,7 +4692,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5560,10 +5570,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6164,6 +6174,19 @@ dependencies = [ "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]] name = "tree-sitter" version = "0.24.7" @@ -6722,7 +6745,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Dockerfile.agent b/Dockerfile.agent index adc4a9d..eefe1f7 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -40,6 +40,19 @@ RUN pip3 install --break-system-packages semgrep # Install ruff for Python linting 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 documentation for the help chat assistant diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 3a61ee8..39330ef 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -15,9 +15,11 @@ 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 drives the reproducible build (NixBackend seal_and_build) that +# 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-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_json = { workspace = true } tokio = { workspace = true } diff --git a/compliance-agent/src/pipeline/firmware_sbom.rs b/compliance-agent/src/pipeline/firmware_sbom.rs index 7a8feae..784ac8f 100644 --- a/compliance-agent/src/pipeline/firmware_sbom.rs +++ b/compliance-agent/src/pipeline/firmware_sbom.rs @@ -1,21 +1,24 @@ -//! Analysis-based firmware SBOM. +//! Firmware SBOM via tramiton. //! -//! 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. +//! 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. //! -//! 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. +//! 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 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 { matches!( 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 -/// 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. +/// 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 { 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, + 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 { + 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(); } - Err(e) => { - tracing::warn!(repo_id, error = %e, "Firmware SBOM: analyze task join error"); - 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") + } } - }; - let Some(bp) = plan else { - return Vec::new(); - }; + } 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 { 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 - // firmware is built (arm-none-eabi-gcc, zephyr-sdk, ...). +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 { + 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( @@ -63,19 +138,15 @@ pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec "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(), + "library".to_string(), ); entry.purl = lib.source; entries.push(entry); } - entries }