Compare commits

..
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 a799a9890a fix(onboarding): targets visibility + unified pipeline by default
CI / Check (pull_request) Successful in 5m52s
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
The onboarding wizard created OnboardedTargets that were invisible in the
dashboard, and triggering a scan failed with "Repository <id> not found":
`/targets/{id}/scan` went through `run_scan`, which consulted the global
`unified_pipeline` flag and fell to the legacy repository pipeline (reads
`repositories`, not `onboarded_targets`).

Agent
- Add `ComplianceAgent::run_target_scan`, always dispatching to the unified
  `run_target` pipeline. The target-scan endpoint operates on
  `onboarded_targets` by construction, so it must not depend on the
  transition flag. `trigger_target_scan` now calls it.
- Default `UNIFIED_PIPELINE` to on (no legacy `repositories` data in prod);
  set `UNIFIED_PIPELINE=0` to opt back to the legacy pipeline.
- Scheduler now scans `onboarded_targets` (via `run_target_scan`) instead of
  the legacy `repositories` collection.

Dashboard
- New Targets page (`/targets`): lists onboarded targets with detected type,
  artifacts, findings count, applicable-scans matrix (on expand), plus Run
  scan and Delete. Sidebar "Repositories" nav becomes "Targets".
- Remove the "Add Repository" form from the Repositories page — onboarding
  is the single entry point (private-repo auth + issue tracker move into the
  onboarding flow, revisable on the target).
- Add `delete_target` server fn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:11:37 +02:00
Sharang ParnerkarandClaude Fable 5 9919afa0ee feat(onboarding): scan-trigger endpoint + wizard Run-Scan button
CI / Check (pull_request) Successful in 5m40s
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
Complete the onboard -> scan -> results loop in-UI:
- POST /api/v1/targets/{id}/scan (404s unknown targets) spawns run_scan, which
  dispatches to the unified pipeline under UNIFIED_PIPELINE.
- Wizard Done step gains a "Run scan" button (server fn trigger_target_scan)
  with inline status, so a user can onboard and kick off a scan without curl.

Passes fmt + clippy -D warnings (agent, dashboard server + web).

Closes #126, #127. Refs #131, #133, #118.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:13:26 +02:00
9 changed files with 21 additions and 293 deletions
+2 -5
View File
@@ -29,13 +29,10 @@ env:
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
# Cancel superseded PR runs, but NEVER cancel main-branch runs — those build and
# deploy per-service images, and cancelling one merge's deploy when the next
# merge lands leaves a service un-deployed (as happened between two back-to-back
# merges). So cancel-in-progress only for pull_request events.
# Cancel in-progress runs for the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
Generated
+5 -43
View File
@@ -693,8 +693,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"tramiton-core",
"tramiton-repro",
"tramiton-sbom",
"urlencoding",
"uuid",
"walkdir",
@@ -2103,7 +2101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3698,7 +3696,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.61.2",
]
[[package]]
@@ -3769,15 +3767,6 @@ 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"
@@ -4692,7 +4681,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5573,7 +5562,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6160,33 +6149,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tramiton-repro"
version = "0.4.0"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.0#e3dc1bf7027a2f6d7b1fe43043d6dfa887ce4af3"
dependencies = [
"serde",
"sha2",
"tempfile",
"thiserror 1.0.69",
"toml",
"tramiton-core",
"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"
@@ -6745,7 +6707,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
-13
View File
@@ -40,19 +40,6 @@ 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
-5
View File
@@ -15,11 +15,6 @@ 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 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 }
@@ -1,152 +0,0 @@
//! Firmware SBOM via tramiton.
//!
//! 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.
//!
//! 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 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 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();
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();
}
};
// 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();
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 {
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(
repo_id.to_string(),
id,
version,
"toolchain".to_string(),
));
}
for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) {
let mut entry = SbomEntry::new(
repo_id.to_string(),
lib.name,
lib.revision,
"library".to_string(),
);
entry.purl = lib.source;
entries.push(entry);
}
entries
}
-1
View File
@@ -1,7 +1,6 @@
pub mod code_review;
pub mod cve;
pub mod dedup;
pub mod firmware_sbom;
pub mod git;
pub mod gitleaks;
mod graph_build;
@@ -461,25 +461,6 @@ impl PipelineOrchestrator {
} },
)
.await?;
// Refresh the target's cached findings count. The shared pipeline
// (Stage 7) increments `repositories`, which the unified path does
// not use, so set the accurate total on the target itself.
let total = self
.db
.findings()
.count_documents(doc! { "repo_id": target_id })
.await
.unwrap_or(*count as u64);
self.db
.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$set": {
"findings_count": total as i64,
"updated_at": mongodb::bson::DateTime::now(),
} },
)
.await?;
}
Err(e) => {
tracing::error!(target_id, error = %e, "Unified scan pipeline failed");
@@ -601,47 +582,6 @@ 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
+5 -5
View File
@@ -20,7 +20,7 @@ pub fn FindingsPage() -> Element {
let mut selected_ids = use_signal(Vec::<String>::new);
let repos = use_resource(|| async {
crate::infrastructure::onboarding::fetch_targets()
crate::infrastructure::repositories::fetch_repositories(1)
.await
.ok()
});
@@ -86,14 +86,14 @@ pub fn FindingsPage() -> Element {
}
select {
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
option { value: "", "All Targets" }
option { value: "", "All Repositories" }
{
match &*repos.read() {
Some(Some(resp)) => rsx! {
for t in &resp.data {
for repo in &resp.data {
{
let id = t.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = t.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
rsx! {
option { value: "{id}", "{name}" }
}
+9 -9
View File
@@ -28,9 +28,9 @@ pub fn SbomPage() -> Element {
let mut diff_repo_a = use_signal(String::new);
let mut diff_repo_b = use_signal(String::new);
// ── Targets for dropdowns ──
// ── Repos for dropdowns ──
let repos = use_resource(|| async {
crate::infrastructure::onboarding::fetch_targets()
crate::infrastructure::repositories::fetch_repositories(1)
.await
.ok()
});
@@ -114,14 +114,14 @@ pub fn SbomPage() -> Element {
select {
class: "sbom-filter-select",
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
option { value: "", "All Targets" }
option { value: "", "All Repositories" }
{
match &*repos.read() {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
rsx! { option { value: "{id}", "{name}" } }
}
}
@@ -476,8 +476,8 @@ pub fn SbomPage() -> Element {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
rsx! { option { value: "{id}", "{name}" } }
}
}
@@ -498,8 +498,8 @@ pub fn SbomPage() -> Element {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
rsx! { option { value: "{id}", "{name}" } }
}
}