Compare commits

...
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 c2d43c55e7 feat(agent): real nix (sandbox=false) for firmware SBOM, replacing nix-portable
CI / Check (pull_request) Successful in 5m26s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
nix-portable fell back to proot in the deployment (user namespaces are blocked
by the container's default seccomp/apparmor profile, and orca can't relax it),
and proot corrupts the nix build's file-permission syscalls — every firmware
build failed at `cp: setting permissions … No such file or directory` and fell
back to the analysis-only SBOM.

Ship a real nix instead and disable its build sandbox (`sandbox = false`): a
plain gcc/make firmware build needs no user namespace, so it runs under the
locked-down profile with no proot at all. The store ships as a compressed
bootstrap tarball (built in a throwaway `nixos/nix` stage) and is seeded onto
/nix at first start by docker/agent-entrypoint.sh, so a persistent /nix volume
survives redeploys. Seeding and the whole path are best-effort — a broken nix
just falls back to analysis-only, never breaking a scan.

No agent code change: NixBackend::detect() already prefers the system `nix`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:54:55 +02:00
sharang 71aceafa26 fix(deps): bump tramiton to v0.4.1 (proot-safe firmware build) (#158)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Successful in 1m44s
CI / Deploy Agent (push) Successful in 3m44s
CI / Deploy Dashboard (push) Successful in 2m34s
CI / Deploy Docs (push) Has been skipped
2026-07-13 11:21:43 +00:00
sharang b12d18d99d 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
2026-07-13 10:15:06 +00:00
5 changed files with 194 additions and 48 deletions
Generated
+30 -6
View File
@@ -694,6 +694,7 @@ dependencies = [
"tracing-subscriber",
"tramiton-core",
"tramiton-repro",
"tramiton-sbom",
"urlencoding",
"uuid",
"walkdir",
@@ -3697,7 +3698,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[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"
@@ -4669,7 +4679,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6140,8 +6150,8 @@ dependencies = [
[[package]]
name = "tramiton-core"
version = "0.4.0"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"serde",
"tempfile",
@@ -6152,10 +6162,11 @@ dependencies = [
[[package]]
name = "tramiton-repro"
version = "0.4.0"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
@@ -6164,6 +6175,19 @@ dependencies = [
"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]]
name = "tree-sitter"
version = "0.24.7"
+31 -1
View File
@@ -13,6 +13,12 @@ RUN --mount=type=secret,id=tramiton_token \
fi && \
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
# A throwaway stage that packs a real nix store (store paths + the validity DB)
# into a compressed bootstrap tarball. Only the tarball is copied into the final
# image, so we don't carry a raw /nix copy layer.
FROM nixos/nix:latest AS nixseed
RUN tar -C / -czf /nix-bootstrap.tar.gz nix
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip npm golang-go php-cli && rm -rf /var/lib/apt/lists/*
@@ -40,7 +46,30 @@ RUN pip3 install --break-system-packages semgrep
# Install ruff for Python linting
RUN pip3 install --break-system-packages ruff
# Real nix for the tramiton reproducible-build firmware SBOM.
#
# nix-portable's proot fallback can't run here: user namespaces are blocked by
# the container's default seccomp/apparmor profile, and orca exposes no way to
# relax it. So ship a *real* nix and disable its build sandbox
# (`sandbox = false`) — a plain gcc/make firmware build needs no user namespace,
# so it runs fine under the locked-down profile with no proot involved.
#
# The store is shipped as a bootstrap tarball and seeded onto /nix at first
# start (see docker/agent-entrypoint.sh), so a persistent /nix volume survives
# redeploys. A missing/broken nix just falls back to the analysis-only SBOM.
COPY --from=nixseed /nix-bootstrap.tar.gz /opt/nix-bootstrap.tar.gz
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
RUN mkdir -p /etc/nix && printf '%s\n' \
'experimental-features = nix-command flakes' \
'sandbox = false' \
'build-users-group =' \
'substituters = https://cache.nixos.org' \
'trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=' \
> /etc/nix/nix.conf
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
COPY docker/agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh
RUN chmod +x /usr/local/bin/agent-entrypoint.sh
# Copy documentation for the help chat assistant
COPY --from=builder /app/README.md /app/README.md
@@ -52,5 +81,6 @@ RUN mkdir -p /data/compliance-scanner/ssh
EXPOSE 3001 3002
ENTRYPOINT ["compliance-agent"]
# Seeds /nix (fresh volume) from the bootstrap tarball, then runs the agent.
ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
+6 -4
View File
@@ -14,10 +14,12 @@ compliance-dast = { path = "../compliance-dast" }
# 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
# 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 = { 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.1" }
# 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.1" }
# 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_json = { workspace = true }
tokio = { workspace = true }
+108 -37
View File
@@ -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<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,
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();
}
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<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
}
// 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<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(
@@ -63,19 +138,15 @@ pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry>
"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
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Seed the nix store on first start, then run the agent.
#
# The firmware-SBOM pipeline drives a real `nix` build (tramiton NixBackend).
# The image ships the store as a bootstrap tarball rather than baking /nix, so a
# persistent /nix volume (mounted empty on first deploy) gets populated once and
# then survives redeploys. Seeding is best-effort: if it fails, the agent still
# starts and firmware SBOMs fall back to analysis-only.
if [ ! -e /nix/store ]; then
echo "agent-entrypoint: seeding /nix store from image bootstrap..."
mkdir -p /nix
if tar -C / -xzf /opt/nix-bootstrap.tar.gz; then
echo "agent-entrypoint: /nix store seeded."
else
echo "agent-entrypoint: WARN nix seed failed; firmware SBOM will use analysis-only fallback."
fi
fi
exec compliance-agent "$@"