refactor: modularize codebase and add 404 unit tests (#13)
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
This commit was merged in pull request #13.
This commit is contained in:
379
compliance-agent/src/api/handlers/sbom.rs
Normal file
379
compliance-agent/src/api/handlers/sbom.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
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<Json<serde_json::Value>, StatusCode> {
|
||||
let db = &agent.db;
|
||||
|
||||
let managers: Vec<String> = 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<String> = 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<SbomFilter>,
|
||||
) -> ApiResult<Vec<SbomEntry>> {
|
||||
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<SbomExportParams>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let db = &agent.db;
|
||||
let entries: Vec<SbomEntry> = 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<serde_json::Value> = 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<serde_json::Value> = 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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
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<SbomFilter>,
|
||||
) -> ApiResult<Vec<LicenseSummary>> {
|
||||
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<SbomEntry> = 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<String, Vec<String>> =
|
||||
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<LicenseSummary> = 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<SbomDiffParams>,
|
||||
) -> ApiResult<SbomDiffResult> {
|
||||
let db = &agent.db;
|
||||
|
||||
let entries_a: Vec<SbomEntry> = 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<SbomEntry> = 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<SbomDiffEntry> = 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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user