Some checks failed
CI / Format (pull_request) Successful in 4s
CI / Clippy (push) Failing after 2m48s
CI / Detect Changes (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) 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
CI / Clippy (pull_request) Failing after 2m34s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Format (push) Successful in 3s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
948 lines
26 KiB
Rust
948 lines
26 KiB
Rust
pub mod chat;
|
|
pub mod dast;
|
|
pub mod graph;
|
|
|
|
use std::sync::Arc;
|
|
|
|
#[allow(unused_imports)]
|
|
use axum::extract::{Extension, Path, Query};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
use axum::Json;
|
|
use mongodb::bson::doc;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use compliance_core::models::*;
|
|
|
|
use crate::agent::ComplianceAgent;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PaginationParams {
|
|
#[serde(default = "default_page")]
|
|
pub page: u64,
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
}
|
|
|
|
fn default_page() -> u64 {
|
|
1
|
|
}
|
|
fn default_limit() -> i64 {
|
|
50
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct FindingsFilter {
|
|
#[serde(default)]
|
|
pub repo_id: Option<String>,
|
|
#[serde(default)]
|
|
pub severity: Option<String>,
|
|
#[serde(default)]
|
|
pub scan_type: Option<String>,
|
|
#[serde(default)]
|
|
pub status: Option<String>,
|
|
#[serde(default)]
|
|
pub q: Option<String>,
|
|
#[serde(default)]
|
|
pub sort_by: Option<String>,
|
|
#[serde(default)]
|
|
pub sort_order: Option<String>,
|
|
#[serde(default = "default_page")]
|
|
pub page: u64,
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ApiResponse<T: Serialize> {
|
|
pub data: T,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub page: Option<u64>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct OverviewStats {
|
|
pub total_repositories: u64,
|
|
pub total_findings: u64,
|
|
pub critical_findings: u64,
|
|
pub high_findings: u64,
|
|
pub medium_findings: u64,
|
|
pub low_findings: u64,
|
|
pub total_sbom_entries: u64,
|
|
pub total_cve_alerts: u64,
|
|
pub total_issues: u64,
|
|
pub recent_scans: Vec<ScanRun>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AddRepositoryRequest {
|
|
pub name: String,
|
|
pub git_url: String,
|
|
#[serde(default = "default_branch")]
|
|
pub default_branch: String,
|
|
pub auth_token: Option<String>,
|
|
pub auth_username: Option<String>,
|
|
pub tracker_type: Option<TrackerType>,
|
|
pub tracker_owner: Option<String>,
|
|
pub tracker_repo: Option<String>,
|
|
pub scan_schedule: Option<String>,
|
|
}
|
|
|
|
fn default_branch() -> String {
|
|
"main".to_string()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateStatusRequest {
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BulkUpdateStatusRequest {
|
|
pub ids: Vec<String>,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateFeedbackRequest {
|
|
pub feedback: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SbomFilter {
|
|
#[serde(default)]
|
|
pub repo_id: Option<String>,
|
|
#[serde(default)]
|
|
pub package_manager: Option<String>,
|
|
#[serde(default)]
|
|
pub q: Option<String>,
|
|
#[serde(default)]
|
|
pub has_vulns: Option<bool>,
|
|
#[serde(default)]
|
|
pub license: Option<String>,
|
|
#[serde(default = "default_page")]
|
|
pub page: u64,
|
|
#[serde(default = "default_limit")]
|
|
pub limit: i64,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SbomExportParams {
|
|
pub repo_id: String,
|
|
#[serde(default = "default_export_format")]
|
|
pub format: String,
|
|
}
|
|
|
|
fn default_export_format() -> String {
|
|
"cyclonedx".to_string()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SbomDiffParams {
|
|
pub repo_a: String,
|
|
pub repo_b: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LicenseSummary {
|
|
pub license: String,
|
|
pub count: u64,
|
|
pub is_copyleft: bool,
|
|
pub packages: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SbomDiffResult {
|
|
pub only_in_a: Vec<SbomDiffEntry>,
|
|
pub only_in_b: Vec<SbomDiffEntry>,
|
|
pub version_changed: Vec<SbomVersionDiff>,
|
|
pub common_count: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SbomDiffEntry {
|
|
pub name: String,
|
|
pub version: String,
|
|
pub package_manager: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SbomVersionDiff {
|
|
pub name: String,
|
|
pub package_manager: String,
|
|
pub version_a: String,
|
|
pub version_b: String,
|
|
}
|
|
|
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
|
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
|
|
|
pub async fn health() -> Json<serde_json::Value> {
|
|
Json(serde_json::json!({ "status": "ok" }))
|
|
}
|
|
|
|
pub async fn stats_overview(Extension(agent): AgentExt) -> ApiResult<OverviewStats> {
|
|
let db = &agent.db;
|
|
|
|
let total_repositories = db
|
|
.repositories()
|
|
.count_documents(doc! {})
|
|
.await
|
|
.unwrap_or(0);
|
|
let total_findings = db.findings().count_documents(doc! {}).await.unwrap_or(0);
|
|
let critical_findings = db
|
|
.findings()
|
|
.count_documents(doc! { "severity": "critical" })
|
|
.await
|
|
.unwrap_or(0);
|
|
let high_findings = db
|
|
.findings()
|
|
.count_documents(doc! { "severity": "high" })
|
|
.await
|
|
.unwrap_or(0);
|
|
let medium_findings = db
|
|
.findings()
|
|
.count_documents(doc! { "severity": "medium" })
|
|
.await
|
|
.unwrap_or(0);
|
|
let low_findings = db
|
|
.findings()
|
|
.count_documents(doc! { "severity": "low" })
|
|
.await
|
|
.unwrap_or(0);
|
|
let total_sbom_entries = db
|
|
.sbom_entries()
|
|
.count_documents(doc! {})
|
|
.await
|
|
.unwrap_or(0);
|
|
let total_cve_alerts = db.cve_alerts().count_documents(doc! {}).await.unwrap_or(0);
|
|
let total_issues = db
|
|
.tracker_issues()
|
|
.count_documents(doc! {})
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
let recent_scans: Vec<ScanRun> = match db
|
|
.scan_runs()
|
|
.find(doc! {})
|
|
.sort(doc! { "started_at": -1 })
|
|
.limit(10)
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: OverviewStats {
|
|
total_repositories,
|
|
total_findings,
|
|
critical_findings,
|
|
high_findings,
|
|
medium_findings,
|
|
low_findings,
|
|
total_sbom_entries,
|
|
total_cve_alerts,
|
|
total_issues,
|
|
recent_scans,
|
|
},
|
|
total: None,
|
|
page: None,
|
|
}))
|
|
}
|
|
|
|
pub async fn list_repositories(
|
|
Extension(agent): AgentExt,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Vec<TrackedRepository>> {
|
|
let db = &agent.db;
|
|
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
|
let total = db
|
|
.repositories()
|
|
.count_documents(doc! {})
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
let repos = match db
|
|
.repositories()
|
|
.find(doc! {})
|
|
.skip(skip)
|
|
.limit(params.limit)
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: repos,
|
|
total: Some(total),
|
|
page: Some(params.page),
|
|
}))
|
|
}
|
|
|
|
pub async fn add_repository(
|
|
Extension(agent): AgentExt,
|
|
Json(req): Json<AddRepositoryRequest>,
|
|
) -> Result<Json<ApiResponse<TrackedRepository>>, (StatusCode, String)> {
|
|
// Validate repository access before saving
|
|
let creds = crate::pipeline::git::RepoCredentials {
|
|
ssh_key_path: Some(agent.config.ssh_key_path.clone()),
|
|
auth_token: req.auth_token.clone(),
|
|
auth_username: req.auth_username.clone(),
|
|
};
|
|
|
|
if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Cannot access repository: {e}"),
|
|
));
|
|
}
|
|
|
|
let mut repo = TrackedRepository::new(req.name, req.git_url);
|
|
repo.default_branch = req.default_branch;
|
|
repo.auth_token = req.auth_token;
|
|
repo.auth_username = req.auth_username;
|
|
repo.tracker_type = req.tracker_type;
|
|
repo.tracker_owner = req.tracker_owner;
|
|
repo.tracker_repo = req.tracker_repo;
|
|
repo.scan_schedule = req.scan_schedule;
|
|
|
|
agent
|
|
.db
|
|
.repositories()
|
|
.insert_one(&repo)
|
|
.await
|
|
.map_err(|_| {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
"Repository already exists".to_string(),
|
|
)
|
|
})?;
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: repo,
|
|
total: None,
|
|
page: None,
|
|
}))
|
|
}
|
|
|
|
pub async fn get_ssh_public_key(
|
|
Extension(agent): AgentExt,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let public_path = format!("{}.pub", agent.config.ssh_key_path);
|
|
let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
|
Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
|
|
}
|
|
|
|
pub async fn trigger_scan(
|
|
Extension(agent): AgentExt,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let agent_clone = (*agent).clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await {
|
|
tracing::error!("Manual scan failed for {id}: {e}");
|
|
}
|
|
});
|
|
|
|
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
|
}
|
|
|
|
pub async fn delete_repository(
|
|
Extension(agent): AgentExt,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
let db = &agent.db;
|
|
|
|
// Delete the repository
|
|
let result = db
|
|
.repositories()
|
|
.delete_one(doc! { "_id": oid })
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
if result.deleted_count == 0 {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// Cascade delete all related data
|
|
let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db
|
|
.tracker_issues()
|
|
.delete_many(doc! { "repo_id": &id })
|
|
.await;
|
|
let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
|
|
let _ = db
|
|
.impact_analyses()
|
|
.delete_many(doc! { "repo_id": &id })
|
|
.await;
|
|
let _ = db
|
|
.code_embeddings()
|
|
.delete_many(doc! { "repo_id": &id })
|
|
.await;
|
|
let _ = db
|
|
.embedding_builds()
|
|
.delete_many(doc! { "repo_id": &id })
|
|
.await;
|
|
|
|
Ok(Json(serde_json::json!({ "status": "deleted" })))
|
|
}
|
|
|
|
pub async fn list_findings(
|
|
Extension(agent): AgentExt,
|
|
Query(filter): Query<FindingsFilter>,
|
|
) -> ApiResult<Vec<Finding>> {
|
|
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(severity) = &filter.severity {
|
|
query.insert("severity", severity);
|
|
}
|
|
if let Some(scan_type) = &filter.scan_type {
|
|
query.insert("scan_type", scan_type);
|
|
}
|
|
if let Some(status) = &filter.status {
|
|
query.insert("status", status);
|
|
}
|
|
// Text search across title, description, file_path, rule_id
|
|
if let Some(q) = &filter.q {
|
|
if !q.is_empty() {
|
|
let regex = doc! { "$regex": q, "$options": "i" };
|
|
query.insert(
|
|
"$or",
|
|
mongodb::bson::bson!([
|
|
{ "title": regex.clone() },
|
|
{ "description": regex.clone() },
|
|
{ "file_path": regex.clone() },
|
|
{ "rule_id": regex },
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Dynamic sort
|
|
let sort_field = filter.sort_by.as_deref().unwrap_or("created_at");
|
|
let sort_dir: i32 = match filter.sort_order.as_deref() {
|
|
Some("asc") => 1,
|
|
_ => -1,
|
|
};
|
|
let sort_doc = doc! { sort_field: sort_dir };
|
|
|
|
let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
|
|
let total = db
|
|
.findings()
|
|
.count_documents(query.clone())
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
let findings = match db
|
|
.findings()
|
|
.find(query)
|
|
.sort(sort_doc)
|
|
.skip(skip)
|
|
.limit(filter.limit)
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: findings,
|
|
total: Some(total),
|
|
page: Some(filter.page),
|
|
}))
|
|
}
|
|
|
|
pub async fn get_finding(
|
|
Extension(agent): AgentExt,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<ApiResponse<Finding>>, StatusCode> {
|
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
let finding = agent
|
|
.db
|
|
.findings()
|
|
.find_one(doc! { "_id": oid })
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: finding,
|
|
total: None,
|
|
page: None,
|
|
}))
|
|
}
|
|
|
|
pub async fn update_finding_status(
|
|
Extension(agent): AgentExt,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<UpdateStatusRequest>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
|
|
agent
|
|
.db
|
|
.findings()
|
|
.update_one(
|
|
doc! { "_id": oid },
|
|
doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
|
|
)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
Ok(Json(serde_json::json!({ "status": "updated" })))
|
|
}
|
|
|
|
pub async fn bulk_update_finding_status(
|
|
Extension(agent): AgentExt,
|
|
Json(req): Json<BulkUpdateStatusRequest>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let oids: Vec<mongodb::bson::oid::ObjectId> = req
|
|
.ids
|
|
.iter()
|
|
.filter_map(|id| mongodb::bson::oid::ObjectId::parse_str(id).ok())
|
|
.collect();
|
|
|
|
if oids.is_empty() {
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let result = agent
|
|
.db
|
|
.findings()
|
|
.update_many(
|
|
doc! { "_id": { "$in": oids } },
|
|
doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
|
|
)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
Ok(Json(
|
|
serde_json::json!({ "status": "updated", "modified_count": result.modified_count }),
|
|
))
|
|
}
|
|
|
|
pub async fn update_finding_feedback(
|
|
Extension(agent): AgentExt,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<UpdateFeedbackRequest>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
|
|
agent
|
|
.db
|
|
.findings()
|
|
.update_one(
|
|
doc! { "_id": oid },
|
|
doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } },
|
|
)
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
Ok(Json(serde_json::json!({ "status": "updated" })))
|
|
}
|
|
|
|
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(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: entries,
|
|
total: Some(total),
|
|
page: Some(filter.page),
|
|
}))
|
|
}
|
|
|
|
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(_) => 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,
|
|
))
|
|
}
|
|
|
|
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",
|
|
];
|
|
|
|
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(_) => 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,
|
|
}))
|
|
}
|
|
|
|
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(_) => 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(_) => 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,
|
|
}))
|
|
}
|
|
|
|
pub async fn list_issues(
|
|
Extension(agent): AgentExt,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Vec<TrackerIssue>> {
|
|
let db = &agent.db;
|
|
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
|
let total = db
|
|
.tracker_issues()
|
|
.count_documents(doc! {})
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
let issues = match db
|
|
.tracker_issues()
|
|
.find(doc! {})
|
|
.sort(doc! { "created_at": -1 })
|
|
.skip(skip)
|
|
.limit(params.limit)
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: issues,
|
|
total: Some(total),
|
|
page: Some(params.page),
|
|
}))
|
|
}
|
|
|
|
pub async fn list_scan_runs(
|
|
Extension(agent): AgentExt,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Vec<ScanRun>> {
|
|
let db = &agent.db;
|
|
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
|
let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0);
|
|
|
|
let scans = match db
|
|
.scan_runs()
|
|
.find(doc! {})
|
|
.sort(doc! { "started_at": -1 })
|
|
.skip(skip)
|
|
.limit(params.limit)
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
Ok(Json(ApiResponse {
|
|
data: scans,
|
|
total: Some(total),
|
|
page: Some(params.page),
|
|
}))
|
|
}
|
|
|
|
async fn collect_cursor_async<T: serde::de::DeserializeOwned + Unpin + Send>(
|
|
mut cursor: mongodb::Cursor<T>,
|
|
) -> Vec<T> {
|
|
use futures_util::StreamExt;
|
|
let mut items = Vec::new();
|
|
while let Some(result) = cursor.next().await {
|
|
match result {
|
|
Ok(item) => items.push(item),
|
|
Err(e) => tracing::warn!("Failed to deserialize document: {e}"),
|
|
}
|
|
}
|
|
items
|
|
}
|