use axum::extract::{Extension, Query}; use axum::http::{header, StatusCode}; use axum::response::IntoResponse; use axum::Json; use mongodb::bson::doc; use super::dto::*; use compliance_core::models::SbomEntry; const COPYLEFT_LICENSES: &[&str] = &[ "GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later", "LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later", "MPL-2.0", ]; #[tracing::instrument(skip_all)] pub async fn sbom_filters( Extension(agent): AgentExt, ) -> Result, StatusCode> { let db = &agent.db; let managers: Vec = db .sbom_entries() .distinct("package_manager", doc! {}) .await .unwrap_or_default() .into_iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .filter(|s| !s.is_empty() && s != "unknown" && s != "file") .collect(); let licenses: Vec = db .sbom_entries() .distinct("license", doc! {}) .await .unwrap_or_default() .into_iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .filter(|s| !s.is_empty()) .collect(); Ok(Json(serde_json::json!({ "package_managers": managers, "licenses": licenses, }))) } #[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, package_manager = ?filter.package_manager))] pub async fn list_sbom( Extension(agent): AgentExt, Query(filter): Query, ) -> ApiResult> { let db = &agent.db; let mut query = doc! {}; if let Some(repo_id) = &filter.repo_id { query.insert("repo_id", repo_id); } if let Some(pm) = &filter.package_manager { query.insert("package_manager", pm); } if let Some(q) = &filter.q { if !q.is_empty() { query.insert("name", doc! { "$regex": q, "$options": "i" }); } } if let Some(has_vulns) = filter.has_vulns { if has_vulns { query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] }); } else { query.insert("known_vulnerabilities", doc! { "$size": 0 }); } } if let Some(license) = &filter.license { query.insert("license", license); } let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; let total = db .sbom_entries() .count_documents(query.clone()) .await .unwrap_or(0); let entries = match db .sbom_entries() .find(query) .sort(doc! { "name": 1 }) .skip(skip) .limit(filter.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(e) => { tracing::warn!("Failed to fetch SBOM entries: {e}"); Vec::new() } }; Ok(Json(ApiResponse { data: entries, total: Some(total), page: Some(filter.page), })) } #[tracing::instrument(skip_all)] pub async fn export_sbom( Extension(agent): AgentExt, Query(params): Query, ) -> Result { let db = &agent.db; let entries: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_id }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(e) => { tracing::warn!("Failed to fetch SBOM entries for export: {e}"); Vec::new() } }; let body = if params.format == "spdx" { // SPDX 2.3 format let packages: Vec = entries .iter() .enumerate() .map(|(i, e)| { serde_json::json!({ "SPDXID": format!("SPDXRef-Package-{i}"), "name": e.name, "versionInfo": e.version, "downloadLocation": "NOASSERTION", "licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"), "externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({ "referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": p, })]).unwrap_or_default(), }) }) .collect(); serde_json::json!({ "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": format!("sbom-{}", params.repo_id), "documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id), "packages": packages, }) } else { // CycloneDX 1.5 format let components: Vec = entries .iter() .map(|e| { let mut comp = serde_json::json!({ "type": "library", "name": e.name, "version": e.version, "group": e.package_manager, }); if let Some(purl) = &e.purl { comp["purl"] = serde_json::Value::String(purl.clone()); } if let Some(license) = &e.license { comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]); } if !e.known_vulnerabilities.is_empty() { comp["vulnerabilities"] = serde_json::json!( e.known_vulnerabilities.iter().map(|v| serde_json::json!({ "id": v.id, "source": { "name": v.source }, "ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(), })).collect::>() ); } comp }) .collect(); serde_json::json!({ "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1, "metadata": { "component": { "type": "application", "name": format!("repo-{}", params.repo_id), } }, "components": components, }) }; let json_str = serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let filename = if params.format == "spdx" { format!("sbom-{}-spdx.json", params.repo_id) } else { format!("sbom-{}-cyclonedx.json", params.repo_id) }; let disposition = format!("attachment; filename=\"{filename}\""); Ok(( [ ( header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"), ), ( header::CONTENT_DISPOSITION, header::HeaderValue::from_str(&disposition) .unwrap_or_else(|_| header::HeaderValue::from_static("attachment")), ), ], json_str, )) } #[tracing::instrument(skip_all)] pub async fn license_summary( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult> { let db = &agent.db; let mut query = doc! {}; if let Some(repo_id) = ¶ms.repo_id { query.insert("repo_id", repo_id); } let entries: Vec = match db.sbom_entries().find(query).await { Ok(cursor) => collect_cursor_async(cursor).await, Err(e) => { tracing::warn!("Failed to fetch SBOM entries for license summary: {e}"); Vec::new() } }; let mut license_map: std::collections::HashMap> = std::collections::HashMap::new(); for entry in &entries { let lic = entry.license.as_deref().unwrap_or("Unknown").to_string(); license_map.entry(lic).or_default().push(entry.name.clone()); } let mut summaries: Vec = license_map .into_iter() .map(|(license, packages)| { let is_copyleft = COPYLEFT_LICENSES .iter() .any(|c| license.to_uppercase().contains(&c.to_uppercase())); LicenseSummary { license, count: packages.len() as u64, is_copyleft, packages, } }) .collect(); summaries.sort_by(|a, b| b.count.cmp(&a.count)); Ok(Json(ApiResponse { data: summaries, total: None, page: None, })) } #[tracing::instrument(skip_all)] pub async fn sbom_diff( Extension(agent): AgentExt, Query(params): Query, ) -> ApiResult { let db = &agent.db; let entries_a: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_a }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(e) => { tracing::warn!("Failed to fetch SBOM entries for repo_a: {e}"); Vec::new() } }; let entries_b: Vec = match db .sbom_entries() .find(doc! { "repo_id": ¶ms.repo_b }) .await { Ok(cursor) => collect_cursor_async(cursor).await, Err(e) => { tracing::warn!("Failed to fetch SBOM entries for repo_b: {e}"); Vec::new() } }; // Build maps by (name, package_manager) -> version let map_a: std::collections::HashMap<(String, String), String> = entries_a .iter() .map(|e| { ( (e.name.clone(), e.package_manager.clone()), e.version.clone(), ) }) .collect(); let map_b: std::collections::HashMap<(String, String), String> = entries_b .iter() .map(|e| { ( (e.name.clone(), e.package_manager.clone()), e.version.clone(), ) }) .collect(); let mut only_in_a = Vec::new(); let mut version_changed = Vec::new(); let mut common_count: u64 = 0; for (key, ver_a) in &map_a { match map_b.get(key) { None => only_in_a.push(SbomDiffEntry { name: key.0.clone(), version: ver_a.clone(), package_manager: key.1.clone(), }), Some(ver_b) if ver_a != ver_b => { version_changed.push(SbomVersionDiff { name: key.0.clone(), package_manager: key.1.clone(), version_a: ver_a.clone(), version_b: ver_b.clone(), }); } Some(_) => common_count += 1, } } let only_in_b: Vec = map_b .iter() .filter(|(key, _)| !map_a.contains_key(key)) .map(|(key, ver)| SbomDiffEntry { name: key.0.clone(), version: ver.clone(), package_manager: key.1.clone(), }) .collect(); Ok(Json(ApiResponse { data: SbomDiffResult { only_in_a, only_in_b, version_changed, common_count, }, total: None, page: None, })) }