fix: SBOM multi-ecosystem support with correct package managers and licenses (#8)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m28s
CI / Security Audit (push) Failing after 1m52s
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped

This commit was merged in pull request #8.
This commit is contained in:
2026-03-10 12:37:29 +00:00
parent 0065c7c4b2
commit daff5812a6
7 changed files with 340 additions and 28 deletions

View File

@@ -263,7 +263,15 @@ impl PipelineOrchestrator {
}
}
// Persist SBOM entries (upsert by repo_id + name + version)
// Remove stale SBOM entries for this repo before reinserting
if !sbom_entries.is_empty() {
self.db
.sbom_entries()
.delete_many(doc! { "repo_id": &repo.id })
.await?;
}
// Persist SBOM entries
for entry in &sbom_entries {
let filter = doc! {
"repo_id": &entry.repo_id,

View File

@@ -18,12 +18,18 @@ impl Scanner for SbomScanner {
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
let mut entries = Vec::new();
// Generate missing lock files so Syft can resolve the full dependency tree
generate_lockfiles(repo_path).await;
// Run syft for SBOM generation
match run_syft(repo_path, repo_id).await {
Ok(syft_entries) => entries.extend(syft_entries),
Err(e) => tracing::warn!("syft failed: {e}"),
}
// Enrich Cargo entries with license info from cargo metadata
enrich_cargo_licenses(repo_path, &mut entries).await;
// Run cargo-audit for Rust-specific vulns
match run_cargo_audit(repo_path, repo_id).await {
Ok(vulns) => merge_audit_vulns(&mut entries, vulns),
@@ -37,10 +43,153 @@ impl Scanner for SbomScanner {
}
}
/// Generate missing lock files so Syft can resolve the full dependency tree.
/// This handles repos that gitignore their lock files (common for Rust libraries).
async fn generate_lockfiles(repo_path: &Path) {
// Cargo: generate Cargo.lock if Cargo.toml exists without it
if repo_path.join("Cargo.toml").exists() && !repo_path.join("Cargo.lock").exists() {
tracing::info!("generating Cargo.lock for SBOM scan");
let result = tokio::process::Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(repo_path)
.output()
.await;
match result {
Ok(o) if o.status.success() => tracing::info!("Cargo.lock generated"),
Ok(o) => tracing::warn!(
"cargo generate-lockfile failed: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => tracing::warn!("cargo generate-lockfile error: {e}"),
}
}
// pip: generate a requirements lock if only pyproject.toml / setup.py exists
let has_pip_manifest = repo_path.join("pyproject.toml").exists()
|| repo_path.join("setup.py").exists()
|| repo_path.join("setup.cfg").exists();
let has_pip_lock = repo_path.join("requirements.txt").exists()
|| repo_path.join("requirements-lock.txt").exists()
|| repo_path.join("poetry.lock").exists()
|| repo_path.join("Pipfile.lock").exists();
if has_pip_manifest && !has_pip_lock {
// Try pip-compile (pip-tools) first, fall back to pip freeze approach
tracing::info!("attempting to generate pip requirements for SBOM scan");
if repo_path.join("pyproject.toml").exists() {
let result = tokio::process::Command::new("pip-compile")
.args([
"--quiet",
"--output-file",
"requirements.txt",
"pyproject.toml",
])
.current_dir(repo_path)
.output()
.await;
match result {
Ok(o) if o.status.success() => {
tracing::info!("requirements.txt generated via pip-compile")
}
_ => tracing::warn!(
"pip-compile not available or failed, Syft will parse pyproject.toml directly"
),
}
}
}
// npm: generate package-lock.json if package.json exists without it
let has_npm_lock = repo_path.join("package-lock.json").exists()
|| repo_path.join("yarn.lock").exists()
|| repo_path.join("pnpm-lock.yaml").exists();
if repo_path.join("package.json").exists() && !has_npm_lock {
tracing::info!("generating package-lock.json for SBOM scan");
let result = tokio::process::Command::new("npm")
.args(["install", "--package-lock-only", "--ignore-scripts"])
.current_dir(repo_path)
.output()
.await;
match result {
Ok(o) if o.status.success() => tracing::info!("package-lock.json generated"),
Ok(o) => tracing::warn!(
"npm install --package-lock-only failed: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => tracing::warn!("npm lock generation error: {e}"),
}
}
}
/// Enrich Cargo SBOM entries with license info from `cargo metadata`.
/// Syft doesn't read license data from Cargo.lock, so we fill it in.
async fn enrich_cargo_licenses(repo_path: &Path, entries: &mut [SbomEntry]) {
if !repo_path.join("Cargo.toml").exists() {
return;
}
let has_cargo_entries = entries.iter().any(|e| e.package_manager == "cargo");
if !has_cargo_entries {
return;
}
let output = match tokio::process::Command::new("cargo")
.args(["metadata", "--format-version", "1"])
.current_dir(repo_path)
.output()
.await
{
Ok(o) if o.status.success() => o,
Ok(o) => {
tracing::warn!(
"cargo metadata failed: {}",
String::from_utf8_lossy(&o.stderr)
);
return;
}
Err(e) => {
tracing::warn!("cargo metadata error: {e}");
return;
}
};
let meta: CargoMetadata = match serde_json::from_slice(&output.stdout) {
Ok(m) => m,
Err(e) => {
tracing::warn!("failed to parse cargo metadata: {e}");
return;
}
};
// Build a lookup: (name, version) -> license
let license_map: std::collections::HashMap<(&str, &str), &str> = meta
.packages
.iter()
.filter_map(|p| {
p.license
.as_deref()
.map(|l| (p.name.as_str(), p.version.as_str(), l))
})
.map(|(n, v, l)| ((n, v), l))
.collect();
for entry in entries.iter_mut() {
if entry.package_manager != "cargo" || entry.license.is_some() {
continue;
}
if let Some(license) = license_map.get(&(entry.name.as_str(), entry.version.as_str())) {
entry.license = Some(license.to_string());
}
}
}
async fn run_syft(repo_path: &Path, repo_id: &str) -> Result<Vec<SbomEntry>, CoreError> {
let output = tokio::process::Command::new("syft")
.arg(repo_path)
.args(["-o", "cyclonedx-json"])
// Enable remote license lookups for all ecosystems
.env("SYFT_GOLANG_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_PYTHON_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVA_USE_NETWORK", "true")
.output()
.await
.map_err(|e| CoreError::Scanner {
@@ -62,22 +211,19 @@ async fn run_syft(repo_path: &Path, repo_id: &str) -> Result<Vec<SbomEntry>, Cor
.unwrap_or_default()
.into_iter()
.map(|c| {
let package_manager = c
.purl
.as_deref()
.and_then(extract_ecosystem_from_purl)
.unwrap_or_else(|| "unknown".to_string());
let mut entry = SbomEntry::new(
repo_id.to_string(),
c.name,
c.version.unwrap_or_else(|| "unknown".to_string()),
c.component_type.unwrap_or_else(|| "library".to_string()),
package_manager,
);
entry.purl = c.purl;
entry.license = c.licenses.and_then(|ls| {
ls.first().and_then(|l| {
l.license.as_ref().map(|lic| {
lic.id
.clone()
.unwrap_or_else(|| lic.name.clone().unwrap_or_default())
})
})
});
entry.license = c.licenses.and_then(|ls| extract_license(&ls));
entry
})
.collect();
@@ -144,6 +290,7 @@ struct CdxComponent {
name: String,
version: Option<String>,
#[serde(rename = "type")]
#[allow(dead_code)]
component_type: Option<String>,
purl: Option<String>,
licenses: Option<Vec<CdxLicenseWrapper>>,
@@ -152,6 +299,8 @@ struct CdxComponent {
#[derive(serde::Deserialize)]
struct CdxLicenseWrapper {
license: Option<CdxLicense>,
/// SPDX license expression (e.g. "MIT OR Apache-2.0")
expression: Option<String>,
}
#[derive(serde::Deserialize)]
@@ -188,3 +337,62 @@ struct AuditVuln {
id: String,
url: String,
}
// Cargo metadata types
#[derive(serde::Deserialize)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
}
#[derive(serde::Deserialize)]
struct CargoPackage {
name: String,
version: String,
license: Option<String>,
}
/// Extract the best license string from CycloneDX license entries.
/// Handles three formats: expression ("MIT OR Apache-2.0"), license.id ("MIT"), license.name ("MIT License").
fn extract_license(entries: &[CdxLicenseWrapper]) -> Option<String> {
// First pass: look for SPDX expressions (most precise for dual-licensed packages)
for entry in entries {
if let Some(ref expr) = entry.expression {
if !expr.is_empty() {
return Some(expr.clone());
}
}
}
// Second pass: collect license.id or license.name from all entries
let parts: Vec<String> = entries
.iter()
.filter_map(|e| {
e.license.as_ref().and_then(|lic| {
lic.id
.clone()
.or_else(|| lic.name.clone())
.filter(|s| !s.is_empty())
})
})
.collect();
if parts.is_empty() {
return None;
}
Some(parts.join(" OR "))
}
/// Extract the ecosystem/package-manager from a PURL string.
/// e.g. "pkg:npm/lodash@4.17.21" → "npm", "pkg:cargo/serde@1.0" → "cargo"
fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
let rest = purl.strip_prefix("pkg:")?;
let ecosystem = rest.split('/').next()?;
if ecosystem.is_empty() {
return None;
}
// Normalise common PURL types to user-friendly names
let normalised = match ecosystem {
"golang" => "go",
"pypi" => "pip",
_ => ecosystem,
};
Some(normalised.to_string())
}