feat: rag-embedding-ai-chat #1
@@ -5,7 +5,10 @@ COPY . .
|
|||||||
RUN cargo build --release -p compliance-agent
|
RUN cargo build --release -p compliance-agent
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install syft for SBOM generation
|
||||||
|
RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ impl ComplianceAgent {
|
|||||||
config.litellm_url.clone(),
|
config.litellm_url.clone(),
|
||||||
config.litellm_api_key.clone(),
|
config.litellm_api_key.clone(),
|
||||||
config.litellm_model.clone(),
|
config.litellm_model.clone(),
|
||||||
|
config.litellm_embed_model.clone(),
|
||||||
));
|
));
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
|
|||||||
238
compliance-agent/src/api/handlers/chat.rs
Normal file
238
compliance-agent/src/api/handlers/chat.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Extension, Path};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
|
use compliance_core::models::chat::{ChatRequest, ChatResponse, SourceReference};
|
||||||
|
use compliance_core::models::embedding::EmbeddingBuildRun;
|
||||||
|
use compliance_graph::graph::embedding_store::EmbeddingStore;
|
||||||
|
|
||||||
|
use crate::agent::ComplianceAgent;
|
||||||
|
use crate::rag::pipeline::RagPipeline;
|
||||||
|
|
||||||
|
use super::ApiResponse;
|
||||||
|
|
||||||
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||||
|
|
||||||
|
/// POST /api/v1/chat/:repo_id — Send a chat message with RAG context
|
||||||
|
pub async fn chat(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
Json(req): Json<ChatRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<ChatResponse>>, StatusCode> {
|
||||||
|
let pipeline = RagPipeline::new(agent.llm.clone(), agent.db.inner());
|
||||||
|
|
||||||
|
// Step 1: Embed the user's message
|
||||||
|
let query_vectors = agent
|
||||||
|
.llm
|
||||||
|
.embed(vec![req.message.clone()])
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to embed query: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let query_embedding = query_vectors.into_iter().next().ok_or_else(|| {
|
||||||
|
tracing::error!("Empty embedding response");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Step 2: Vector search — retrieve top 8 chunks
|
||||||
|
let search_results = pipeline
|
||||||
|
.store()
|
||||||
|
.vector_search(&repo_id, query_embedding, 8, 0.5)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Vector search failed: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Step 3: Build system prompt with code context
|
||||||
|
let mut context_parts = Vec::new();
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
|
||||||
|
for (embedding, score) in &search_results {
|
||||||
|
context_parts.push(format!(
|
||||||
|
"--- {} ({}, {}:L{}-L{}) ---\n{}",
|
||||||
|
embedding.qualified_name,
|
||||||
|
embedding.kind,
|
||||||
|
embedding.file_path,
|
||||||
|
embedding.start_line,
|
||||||
|
embedding.end_line,
|
||||||
|
embedding.content,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Truncate snippet for the response
|
||||||
|
let snippet: String = embedding
|
||||||
|
.content
|
||||||
|
.lines()
|
||||||
|
.take(10)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
sources.push(SourceReference {
|
||||||
|
file_path: embedding.file_path.clone(),
|
||||||
|
qualified_name: embedding.qualified_name.clone(),
|
||||||
|
start_line: embedding.start_line,
|
||||||
|
end_line: embedding.end_line,
|
||||||
|
language: embedding.language.clone(),
|
||||||
|
snippet,
|
||||||
|
score: *score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let code_context = if context_parts.is_empty() {
|
||||||
|
"No relevant code context found.".to_string()
|
||||||
|
} else {
|
||||||
|
context_parts.join("\n\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
let system_prompt = format!(
|
||||||
|
"You are an expert code assistant for a software repository. \
|
||||||
|
Answer the user's question based on the code context below. \
|
||||||
|
Reference specific files and functions when relevant. \
|
||||||
|
If the context doesn't contain enough information, say so.\n\n\
|
||||||
|
## Code Context\n\n{code_context}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Build messages array with history
|
||||||
|
let mut messages: Vec<(String, String)> = Vec::new();
|
||||||
|
messages.push(("system".to_string(), system_prompt));
|
||||||
|
|
||||||
|
for msg in &req.history {
|
||||||
|
messages.push((msg.role.clone(), msg.content.clone()));
|
||||||
|
}
|
||||||
|
messages.push(("user".to_string(), req.message));
|
||||||
|
|
||||||
|
// Step 5: Call LLM
|
||||||
|
let response_text = agent
|
||||||
|
.llm
|
||||||
|
.chat_with_messages(messages, Some(0.3))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("LLM chat failed: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: ChatResponse {
|
||||||
|
message: response_text,
|
||||||
|
sources,
|
||||||
|
},
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/chat/:repo_id/build-embeddings — Trigger embedding build
|
||||||
|
pub async fn build_embeddings(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let agent_clone = (*agent).clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let repo = match agent_clone
|
||||||
|
.db
|
||||||
|
.repositories()
|
||||||
|
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(r)) => r,
|
||||||
|
_ => {
|
||||||
|
tracing::error!("Repository {repo_id} not found for embedding build");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get latest graph build
|
||||||
|
let build = match agent_clone
|
||||||
|
.db
|
||||||
|
.graph_builds()
|
||||||
|
.find_one(doc! { "repo_id": &repo_id })
|
||||||
|
.sort(doc! { "started_at": -1 })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(b)) => b,
|
||||||
|
_ => {
|
||||||
|
tracing::error!("[{repo_id}] No graph build found — build graph first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let graph_build_id = build
|
||||||
|
.id
|
||||||
|
.map(|id| id.to_hex())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Get nodes
|
||||||
|
let nodes: Vec<compliance_core::models::graph::CodeNode> = match agent_clone
|
||||||
|
.db
|
||||||
|
.graph_nodes()
|
||||||
|
.find(doc! { "repo_id": &repo_id })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(cursor) => {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut cursor = cursor;
|
||||||
|
while let Some(Ok(item)) = cursor.next().await {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path);
|
||||||
|
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to clone repo for embedding build: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pipeline = RagPipeline::new(agent_clone.llm.clone(), agent_clone.db.inner());
|
||||||
|
match pipeline
|
||||||
|
.build_embeddings(&repo_id, &repo_path, &graph_build_id, &nodes)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(run) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[{repo_id}] Embedding build complete: {}/{} chunks",
|
||||||
|
run.embedded_chunks,
|
||||||
|
run.total_chunks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{repo_id}] Embedding build failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({ "status": "embedding_build_triggered" }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/chat/:repo_id/status — Get latest embedding build status
|
||||||
|
pub async fn embedding_status(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
) -> Result<Json<ApiResponse<Option<EmbeddingBuildRun>>>, StatusCode> {
|
||||||
|
let store = EmbeddingStore::new(agent.db.inner());
|
||||||
|
let build = store.get_latest_build(&repo_id).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to get embedding status: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: build,
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -103,8 +103,7 @@ pub async fn trigger_scan(
|
|||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
let oid =
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
let target = agent
|
let target = agent
|
||||||
.db
|
.db
|
||||||
@@ -207,8 +206,7 @@ pub async fn get_finding(
|
|||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
|
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
|
||||||
let oid =
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
let finding = agent
|
let finding = agent
|
||||||
.db
|
.db
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub async fn get_graph(
|
|||||||
// so there is only one set of nodes/edges per repo.
|
// so there is only one set of nodes/edges per repo.
|
||||||
let filter = doc! { "repo_id": &repo_id };
|
let filter = doc! { "repo_id": &repo_id };
|
||||||
|
|
||||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
let all_nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,17 @@ pub async fn get_graph(
|
|||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove disconnected nodes (no edges) to keep the graph clean
|
||||||
|
let connected: std::collections::HashSet<&str> = edges
|
||||||
|
.iter()
|
||||||
|
.flat_map(|e| [e.source.as_str(), e.target.as_str()])
|
||||||
|
.collect();
|
||||||
|
let nodes = all_nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| connected.contains(n.qualified_name.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
(nodes, edges)
|
(nodes, edges)
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
@@ -235,12 +246,7 @@ pub async fn get_file_content(
|
|||||||
// Cap at 10,000 lines
|
// Cap at 10,000 lines
|
||||||
let truncated: String = content.lines().take(10_000).collect::<Vec<_>>().join("\n");
|
let truncated: String = content.lines().take(10_000).collect::<Vec<_>>().join("\n");
|
||||||
|
|
||||||
let language = params
|
let language = params.path.rsplit('.').next().unwrap_or("").to_string();
|
||||||
.path
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: FileContent {
|
data: FileContent {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
|
|
||||||
@@ -5,7 +6,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use axum::extract::{Extension, Path, Query};
|
use axum::extract::{Extension, Path, Query};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mongodb::bson::doc;
|
use mongodb::bson::doc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -89,6 +91,72 @@ pub struct UpdateStatusRequest {
|
|||||||
pub status: String,
|
pub status: 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 AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||||
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
||||||
|
|
||||||
@@ -235,6 +303,52 @@ pub async fn trigger_scan(
|
|||||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
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(
|
pub async fn list_findings(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(filter): Query<FindingsFilter>,
|
Query(filter): Query<FindingsFilter>,
|
||||||
@@ -322,21 +436,46 @@ pub async fn update_finding_status(
|
|||||||
|
|
||||||
pub async fn list_sbom(
|
pub async fn list_sbom(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(params): Query<PaginationParams>,
|
Query(filter): Query<SbomFilter>,
|
||||||
) -> ApiResult<Vec<SbomEntry>> {
|
) -> ApiResult<Vec<SbomEntry>> {
|
||||||
let db = &agent.db;
|
let db = &agent.db;
|
||||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
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
|
let total = db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.count_documents(doc! {})
|
.count_documents(query.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let entries = match db
|
let entries = match db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.find(doc! {})
|
.find(query)
|
||||||
|
.sort(doc! { "name": 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(params.limit)
|
.limit(filter.limit)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
@@ -346,7 +485,272 @@ pub async fn list_sbom(
|
|||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: entries,
|
data: entries,
|
||||||
total: Some(total),
|
total: Some(total),
|
||||||
page: Some(params.page),
|
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::api::handlers;
|
use crate::api::handlers;
|
||||||
@@ -13,6 +13,10 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/repositories/{id}/scan",
|
"/api/v1/repositories/{id}/scan",
|
||||||
post(handlers::trigger_scan),
|
post(handlers::trigger_scan),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/repositories/{id}",
|
||||||
|
delete(handlers::delete_repository),
|
||||||
|
)
|
||||||
.route("/api/v1/findings", get(handlers::list_findings))
|
.route("/api/v1/findings", get(handlers::list_findings))
|
||||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||||
.route(
|
.route(
|
||||||
@@ -20,13 +24,13 @@ pub fn build_router() -> Router {
|
|||||||
patch(handlers::update_finding_status),
|
patch(handlers::update_finding_status),
|
||||||
)
|
)
|
||||||
.route("/api/v1/sbom", get(handlers::list_sbom))
|
.route("/api/v1/sbom", get(handlers::list_sbom))
|
||||||
|
.route("/api/v1/sbom/export", get(handlers::export_sbom))
|
||||||
|
.route("/api/v1/sbom/licenses", get(handlers::license_summary))
|
||||||
|
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
||||||
.route("/api/v1/issues", get(handlers::list_issues))
|
.route("/api/v1/issues", get(handlers::list_issues))
|
||||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
||||||
// Graph API endpoints
|
// Graph API endpoints
|
||||||
.route(
|
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
||||||
"/api/v1/graph/{repo_id}",
|
|
||||||
get(handlers::graph::get_graph),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/graph/{repo_id}/nodes",
|
"/api/v1/graph/{repo_id}/nodes",
|
||||||
get(handlers::graph::get_nodes),
|
get(handlers::graph::get_nodes),
|
||||||
@@ -52,14 +56,8 @@ pub fn build_router() -> Router {
|
|||||||
post(handlers::graph::trigger_build),
|
post(handlers::graph::trigger_build),
|
||||||
)
|
)
|
||||||
// DAST API endpoints
|
// DAST API endpoints
|
||||||
.route(
|
.route("/api/v1/dast/targets", get(handlers::dast::list_targets))
|
||||||
"/api/v1/dast/targets",
|
.route("/api/v1/dast/targets", post(handlers::dast::add_target))
|
||||||
get(handlers::dast::list_targets),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/dast/targets",
|
|
||||||
post(handlers::dast::add_target),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/dast/targets/{id}/scan",
|
"/api/v1/dast/targets/{id}/scan",
|
||||||
post(handlers::dast::trigger_scan),
|
post(handlers::dast::trigger_scan),
|
||||||
@@ -68,12 +66,19 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/dast/scan-runs",
|
"/api/v1/dast/scan-runs",
|
||||||
get(handlers::dast::list_scan_runs),
|
get(handlers::dast::list_scan_runs),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/api/v1/dast/findings", get(handlers::dast::list_findings))
|
||||||
"/api/v1/dast/findings",
|
|
||||||
get(handlers::dast::list_findings),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/dast/findings/{id}",
|
"/api/v1/dast/findings/{id}",
|
||||||
get(handlers::dast::get_finding),
|
get(handlers::dast::get_finding),
|
||||||
)
|
)
|
||||||
|
// Chat / RAG API endpoints
|
||||||
|
.route("/api/v1/chat/{repo_id}", post(handlers::chat::chat))
|
||||||
|
.route(
|
||||||
|
"/api/v1/chat/{repo_id}/build-embeddings",
|
||||||
|
post(handlers::chat::build_embeddings),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/chat/{repo_id}/status",
|
||||||
|
get(handlers::chat::embedding_status),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or_else(|| "http://localhost:4000".to_string()),
|
.unwrap_or_else(|| "http://localhost:4000".to_string()),
|
||||||
litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()),
|
litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()),
|
||||||
litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()),
|
litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()),
|
||||||
|
litellm_embed_model: env_var_opt("LITELLM_EMBED_MODEL")
|
||||||
|
.unwrap_or_else(|| "text-embedding-3-small".to_string()),
|
||||||
github_token: env_secret_opt("GITHUB_TOKEN"),
|
github_token: env_secret_opt("GITHUB_TOKEN"),
|
||||||
github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"),
|
github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"),
|
||||||
gitlab_url: env_var_opt("GITLAB_URL"),
|
gitlab_url: env_var_opt("GITLAB_URL"),
|
||||||
|
|||||||
@@ -127,11 +127,7 @@ impl Database {
|
|||||||
|
|
||||||
// dast_targets: index on repo_id
|
// dast_targets: index on repo_id
|
||||||
self.dast_targets()
|
self.dast_targets()
|
||||||
.create_index(
|
.create_index(IndexModel::builder().keys(doc! { "repo_id": 1 }).build())
|
||||||
IndexModel::builder()
|
|
||||||
.keys(doc! { "repo_id": 1 })
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// dast_scan_runs: compound (target_id, started_at DESC)
|
// dast_scan_runs: compound (target_id, started_at DESC)
|
||||||
@@ -152,6 +148,24 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// code_embeddings: compound (repo_id, graph_build_id)
|
||||||
|
self.code_embeddings()
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "graph_build_id": 1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// embedding_builds: compound (repo_id, started_at DESC)
|
||||||
|
self.embedding_builds()
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "started_at": -1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tracing::info!("Database indexes ensured");
|
tracing::info!("Database indexes ensured");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -210,6 +224,17 @@ impl Database {
|
|||||||
self.inner.collection("dast_findings")
|
self.inner.collection("dast_findings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embedding collections
|
||||||
|
pub fn code_embeddings(&self) -> Collection<compliance_core::models::embedding::CodeEmbedding> {
|
||||||
|
self.inner.collection("code_embeddings")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedding_builds(
|
||||||
|
&self,
|
||||||
|
) -> Collection<compliance_core::models::embedding::EmbeddingBuildRun> {
|
||||||
|
self.inner.collection("embedding_builds")
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||||
self.inner.collection(name)
|
self.inner.collection(name)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct LlmClient {
|
|||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: SecretString,
|
api_key: SecretString,
|
||||||
model: String,
|
model: String,
|
||||||
|
embed_model: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,16 +43,46 @@ struct ChatResponseMessage {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for the embeddings API
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EmbeddingRequest {
|
||||||
|
model: String,
|
||||||
|
input: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the embeddings API
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmbeddingResponse {
|
||||||
|
data: Vec<EmbeddingData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single embedding result
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmbeddingData {
|
||||||
|
embedding: Vec<f64>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
impl LlmClient {
|
impl LlmClient {
|
||||||
pub fn new(base_url: String, api_key: SecretString, model: String) -> Self {
|
pub fn new(
|
||||||
|
base_url: String,
|
||||||
|
api_key: SecretString,
|
||||||
|
model: String,
|
||||||
|
embed_model: String,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url,
|
base_url,
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
|
embed_model,
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn embed_model(&self) -> &str {
|
||||||
|
&self.embed_model
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn chat(
|
pub async fn chat(
|
||||||
&self,
|
&self,
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
@@ -169,4 +200,49 @@ impl LlmClient {
|
|||||||
.map(|c| c.message.content.clone())
|
.map(|c| c.message.content.clone())
|
||||||
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate embeddings for a batch of texts
|
||||||
|
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
||||||
|
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let request_body = EmbeddingRequest {
|
||||||
|
model: self.embed_model.clone(),
|
||||||
|
input: texts,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&request_body);
|
||||||
|
|
||||||
|
let key = self.api_key.expose_secret();
|
||||||
|
if !key.is_empty() {
|
||||||
|
req = req.header("Authorization", format!("Bearer {key}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(AgentError::Other(format!(
|
||||||
|
"Embedding API returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: EmbeddingResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?;
|
||||||
|
|
||||||
|
// Sort by index to maintain input order
|
||||||
|
let mut data = body.data;
|
||||||
|
data.sort_by_key(|d| d.index);
|
||||||
|
|
||||||
|
Ok(data.into_iter().map(|d| d.embedding).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod database;
|
|||||||
mod error;
|
mod error;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
mod rag;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod trackers;
|
mod trackers;
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ impl PipelineOrchestrator {
|
|||||||
// Stage 4.5: Graph Building
|
// Stage 4.5: Graph Building
|
||||||
tracing::info!("[{repo_id}] Stage 4.5: Graph Building");
|
tracing::info!("[{repo_id}] Stage 4.5: Graph Building");
|
||||||
self.update_phase(scan_run_id, "graph_building").await;
|
self.update_phase(scan_run_id, "graph_building").await;
|
||||||
let graph_context = match self.build_code_graph(&repo_path, &repo_id, &all_findings).await
|
let graph_context = match self
|
||||||
|
.build_code_graph(&repo_path, &repo_id, &all_findings)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(ctx) => Some(ctx),
|
Ok(ctx) => Some(ctx),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -296,9 +298,10 @@ impl PipelineOrchestrator {
|
|||||||
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
||||||
let engine = compliance_graph::GraphEngine::new(50_000);
|
let engine = compliance_graph::GraphEngine::new(50_000);
|
||||||
|
|
||||||
let (mut code_graph, build_run) = engine
|
let (mut code_graph, build_run) =
|
||||||
.build_graph(repo_path, repo_id, &graph_build_id)
|
engine
|
||||||
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
.build_graph(repo_path, repo_id, &graph_build_id)
|
||||||
|
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
||||||
|
|
||||||
// Apply community detection
|
// Apply community detection
|
||||||
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
||||||
@@ -348,15 +351,11 @@ impl PipelineOrchestrator {
|
|||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
|
|
||||||
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
||||||
let targets: Vec<compliance_core::models::DastTarget> = match self
|
let targets: Vec<compliance_core::models::DastTarget> =
|
||||||
.db
|
match self.db.dast_targets().find(filter).await {
|
||||||
.dast_targets()
|
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
||||||
.find(filter)
|
Err(_) => return,
|
||||||
.await
|
};
|
||||||
{
|
|
||||||
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
||||||
@@ -379,10 +378,7 @@ impl PipelineOrchestrator {
|
|||||||
tracing::error!("Failed to store DAST finding: {e}");
|
tracing::error!("Failed to store DAST finding: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!("DAST scan complete: {} findings", findings.len());
|
||||||
"DAST scan complete: {} findings",
|
|
||||||
findings.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("DAST scan failed: {e}");
|
tracing::error!("DAST scan failed: {e}");
|
||||||
|
|||||||
1
compliance-agent/src/rag/mod.rs
Normal file
1
compliance-agent/src/rag/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod pipeline;
|
||||||
164
compliance-agent/src/rag/pipeline.rs
Normal file
164
compliance-agent/src/rag/pipeline.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus};
|
||||||
|
use compliance_core::models::graph::CodeNode;
|
||||||
|
use compliance_graph::graph::chunking::extract_chunks;
|
||||||
|
use compliance_graph::graph::embedding_store::EmbeddingStore;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::error::AgentError;
|
||||||
|
use crate::llm::LlmClient;
|
||||||
|
|
||||||
|
/// RAG pipeline for building embeddings and performing retrieval
|
||||||
|
pub struct RagPipeline {
|
||||||
|
llm: Arc<LlmClient>,
|
||||||
|
embedding_store: EmbeddingStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RagPipeline {
|
||||||
|
pub fn new(llm: Arc<LlmClient>, db: &mongodb::Database) -> Self {
|
||||||
|
Self {
|
||||||
|
llm,
|
||||||
|
embedding_store: EmbeddingStore::new(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store(&self) -> &EmbeddingStore {
|
||||||
|
&self.embedding_store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build embeddings for all code nodes in a repository
|
||||||
|
pub async fn build_embeddings(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
repo_path: &Path,
|
||||||
|
graph_build_id: &str,
|
||||||
|
nodes: &[CodeNode],
|
||||||
|
) -> Result<EmbeddingBuildRun, AgentError> {
|
||||||
|
let embed_model = self.llm.embed_model().to_string();
|
||||||
|
let mut build =
|
||||||
|
EmbeddingBuildRun::new(repo_id.to_string(), graph_build_id.to_string(), embed_model);
|
||||||
|
|
||||||
|
// Step 1: Extract chunks
|
||||||
|
let chunks = extract_chunks(repo_path, nodes, 2048);
|
||||||
|
build.total_chunks = chunks.len() as u32;
|
||||||
|
info!(
|
||||||
|
"[{repo_id}] Extracted {} chunks for embedding",
|
||||||
|
chunks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the initial build record
|
||||||
|
self.embedding_store
|
||||||
|
.store_build(&build)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to store build: {e}")))?;
|
||||||
|
|
||||||
|
if chunks.is_empty() {
|
||||||
|
build.status = EmbeddingBuildStatus::Completed;
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
self.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Completed,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?;
|
||||||
|
return Ok(build);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Delete old embeddings for this repo
|
||||||
|
self.embedding_store
|
||||||
|
.delete_repo_embeddings(repo_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to delete old embeddings: {e}")))?;
|
||||||
|
|
||||||
|
// Step 3: Batch embed (small batches to stay within model limits)
|
||||||
|
let batch_size = 20;
|
||||||
|
let mut all_embeddings = Vec::new();
|
||||||
|
let mut embedded_count = 0u32;
|
||||||
|
|
||||||
|
for batch_start in (0..chunks.len()).step_by(batch_size) {
|
||||||
|
let batch_end = (batch_start + batch_size).min(chunks.len());
|
||||||
|
let batch_chunks = &chunks[batch_start..batch_end];
|
||||||
|
|
||||||
|
// Prepare texts: context_header + content
|
||||||
|
let texts: Vec<String> = batch_chunks
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("{}\n{}", c.context_header, c.content))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match self.llm.embed(texts).await {
|
||||||
|
Ok(vectors) => {
|
||||||
|
for (chunk, embedding) in batch_chunks.iter().zip(vectors) {
|
||||||
|
all_embeddings.push(CodeEmbedding {
|
||||||
|
id: None,
|
||||||
|
repo_id: repo_id.to_string(),
|
||||||
|
graph_build_id: graph_build_id.to_string(),
|
||||||
|
qualified_name: chunk.qualified_name.clone(),
|
||||||
|
kind: chunk.kind.clone(),
|
||||||
|
file_path: chunk.file_path.clone(),
|
||||||
|
start_line: chunk.start_line,
|
||||||
|
end_line: chunk.end_line,
|
||||||
|
language: chunk.language.clone(),
|
||||||
|
content: chunk.content.clone(),
|
||||||
|
context_header: chunk.context_header.clone(),
|
||||||
|
embedding,
|
||||||
|
token_estimate: chunk.token_estimate,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
embedded_count += batch_chunks.len() as u32;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("[{repo_id}] Embedding batch failed: {e}");
|
||||||
|
build.status = EmbeddingBuildStatus::Failed;
|
||||||
|
build.error_message = Some(e.to_string());
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
let _ = self
|
||||||
|
.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Failed,
|
||||||
|
embedded_count,
|
||||||
|
Some(e.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Store all embeddings
|
||||||
|
self.embedding_store
|
||||||
|
.store_embeddings(&all_embeddings)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to store embeddings: {e}")))?;
|
||||||
|
|
||||||
|
// Step 5: Update build status
|
||||||
|
build.status = EmbeddingBuildStatus::Completed;
|
||||||
|
build.embedded_chunks = embedded_count;
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
self.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Completed,
|
||||||
|
embedded_count,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[{repo_id}] Embedding build complete: {embedded_count}/{} chunks",
|
||||||
|
build.total_chunks
|
||||||
|
);
|
||||||
|
Ok(build)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub struct AgentConfig {
|
|||||||
pub litellm_url: String,
|
pub litellm_url: String,
|
||||||
pub litellm_api_key: SecretString,
|
pub litellm_api_key: SecretString,
|
||||||
pub litellm_model: String,
|
pub litellm_model: String,
|
||||||
|
pub litellm_embed_model: String,
|
||||||
pub github_token: Option<SecretString>,
|
pub github_token: Option<SecretString>,
|
||||||
pub github_webhook_secret: Option<SecretString>,
|
pub github_webhook_secret: Option<SecretString>,
|
||||||
pub gitlab_url: Option<String>,
|
pub gitlab_url: Option<String>,
|
||||||
|
|||||||
35
compliance-core/src/models/chat.rs
Normal file
35
compliance-core/src/models/chat.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A message in the chat history
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for the chat endpoint
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub history: Vec<ChatMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source reference from the RAG retrieval
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SourceReference {
|
||||||
|
pub file_path: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub snippet: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the chat endpoint
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatResponse {
|
||||||
|
pub message: String,
|
||||||
|
pub sources: Vec<SourceReference>,
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ pub struct DastFinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DastFinding {
|
impl DastFinding {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
scan_run_id: String,
|
scan_run_id: String,
|
||||||
target_id: String,
|
target_id: String,
|
||||||
|
|||||||
100
compliance-core/src/models/embedding.rs
Normal file
100
compliance-core/src/models/embedding.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Status of an embedding build operation
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EmbeddingBuildStatus {
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code embedding stored in MongoDB Atlas Vector Search
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CodeEmbedding {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<bson::oid::ObjectId>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub graph_build_id: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub content: String,
|
||||||
|
pub context_header: String,
|
||||||
|
pub embedding: Vec<f64>,
|
||||||
|
pub token_estimate: u32,
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks an embedding build operation for a repository
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EmbeddingBuildRun {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<bson::oid::ObjectId>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub graph_build_id: String,
|
||||||
|
pub status: EmbeddingBuildStatus,
|
||||||
|
pub total_chunks: u32,
|
||||||
|
pub embedded_chunks: u32,
|
||||||
|
pub embedding_model: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
with = "opt_chrono_as_bson"
|
||||||
|
)]
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingBuildRun {
|
||||||
|
pub fn new(repo_id: String, graph_build_id: String, embedding_model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: None,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
status: EmbeddingBuildStatus::Running,
|
||||||
|
total_chunks: 0,
|
||||||
|
embedded_chunks: 0,
|
||||||
|
embedding_model,
|
||||||
|
error_message: None,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
completed_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde helper for Option<DateTime<Utc>> as BSON DateTime
|
||||||
|
mod opt_chrono_as_bson {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct BsonDt(
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")] DateTime<Utc>,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn serialize<S>(value: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
match value {
|
||||||
|
Some(dt) => BsonDt(*dt).serialize(serializer),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let opt: Option<BsonDt> = Option::deserialize(deserializer)?;
|
||||||
|
Ok(opt.map(|d| d.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod chat;
|
||||||
pub mod cve;
|
pub mod cve;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
|
pub mod embedding;
|
||||||
pub mod finding;
|
pub mod finding;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
@@ -7,15 +9,16 @@ pub mod repository;
|
|||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
|
||||||
|
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
||||||
pub use cve::{CveAlert, CveSource};
|
pub use cve::{CveAlert, CveSource};
|
||||||
pub use dast::{
|
pub use dast::{
|
||||||
DastAuthConfig, DastEvidence, DastFinding, DastScanPhase, DastScanRun, DastScanStatus,
|
DastAuthConfig, DastEvidence, DastFinding, DastScanPhase, DastScanRun, DastScanStatus,
|
||||||
DastTarget, DastTargetType, DastVulnType,
|
DastTarget, DastTargetType, DastVulnType,
|
||||||
};
|
};
|
||||||
|
pub use embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus};
|
||||||
pub use finding::{Finding, FindingStatus, Severity};
|
pub use finding::{Finding, FindingStatus, Severity};
|
||||||
pub use graph::{
|
pub use graph::{
|
||||||
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus,
|
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis,
|
||||||
ImpactAnalysis,
|
|
||||||
};
|
};
|
||||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||||
pub use repository::{ScanTrigger, TrackedRepository};
|
pub use repository::{ScanTrigger, TrackedRepository};
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ pub struct TrackedRepository {
|
|||||||
pub last_scanned_commit: Option<String>,
|
pub last_scanned_commit: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
||||||
pub findings_count: u32,
|
pub findings_count: u32,
|
||||||
#[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")]
|
#[serde(
|
||||||
|
default = "chrono::Utc::now",
|
||||||
|
deserialize_with = "deserialize_datetime"
|
||||||
|
)]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
#[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")]
|
#[serde(
|
||||||
|
default = "chrono::Utc::now",
|
||||||
|
deserialize_with = "deserialize_datetime"
|
||||||
|
)]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +57,7 @@ where
|
|||||||
let bson = bson::Bson::deserialize(deserializer)?;
|
let bson = bson::Bson::deserialize(deserializer)?;
|
||||||
match bson {
|
match bson {
|
||||||
bson::Bson::DateTime(dt) => Ok(dt.into()),
|
bson::Bson::DateTime(dt) => Ok(dt.into()),
|
||||||
bson::Bson::String(s) => s
|
bson::Bson::String(s) => s.parse::<DateTime<Utc>>().map_err(serde::de::Error::custom),
|
||||||
.parse::<DateTime<Utc>>()
|
|
||||||
.map_err(serde::de::Error::custom),
|
|
||||||
other => Err(serde::de::Error::custom(format!(
|
other => Err(serde::de::Error::custom(format!(
|
||||||
"expected DateTime or string, got: {other:?}"
|
"expected DateTime or string, got: {other:?}"
|
||||||
))),
|
))),
|
||||||
|
|||||||
@@ -169,20 +169,20 @@
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
solver: "forceAtlas2Based",
|
solver: "forceAtlas2Based",
|
||||||
forceAtlas2Based: {
|
forceAtlas2Based: {
|
||||||
gravitationalConstant: -60,
|
gravitationalConstant: -80,
|
||||||
centralGravity: 0.012,
|
centralGravity: 0.005,
|
||||||
springLength: 80,
|
springLength: 120,
|
||||||
springConstant: 0.06,
|
springConstant: 0.04,
|
||||||
damping: 0.4,
|
damping: 0.5,
|
||||||
avoidOverlap: 0.5,
|
avoidOverlap: 0.6,
|
||||||
},
|
},
|
||||||
stabilization: {
|
stabilization: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
iterations: 1000,
|
iterations: 1500,
|
||||||
updateInterval: 25,
|
updateInterval: 25,
|
||||||
},
|
},
|
||||||
maxVelocity: 40,
|
maxVelocity: 50,
|
||||||
minVelocity: 0.1,
|
minVelocity: 0.75,
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
hover: true,
|
hover: true,
|
||||||
@@ -252,7 +252,24 @@
|
|||||||
overlay.style.display = "none";
|
overlay.style.display = "none";
|
||||||
}, 900);
|
}, 900);
|
||||||
}
|
}
|
||||||
network.setOptions({ physics: { enabled: false } });
|
// Keep physics running so nodes float and respond to dragging,
|
||||||
|
// but reduce forces for a calm, settled feel
|
||||||
|
network.setOptions({
|
||||||
|
physics: {
|
||||||
|
enabled: true,
|
||||||
|
solver: "forceAtlas2Based",
|
||||||
|
forceAtlas2Based: {
|
||||||
|
gravitationalConstant: -40,
|
||||||
|
centralGravity: 0.003,
|
||||||
|
springLength: 120,
|
||||||
|
springConstant: 0.03,
|
||||||
|
damping: 0.7,
|
||||||
|
avoidOverlap: 0.6,
|
||||||
|
},
|
||||||
|
maxVelocity: 20,
|
||||||
|
minVelocity: 0.75,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -603,6 +603,76 @@ tbody tr:last-child td {
|
|||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-ghost-danger:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #e0334f;
|
||||||
|
box-shadow: 0 0 12px rgba(255, 59, 92, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: var(--bg-card-solid);
|
||||||
|
border: 1px solid var(--border-bright);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px 28px;
|
||||||
|
max-width: 460px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-warning {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -1710,3 +1780,660 @@ tbody tr:last-child td {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── AI Chat ── */
|
||||||
|
|
||||||
|
.chat-embedding-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-embedding-building {
|
||||||
|
border-color: var(--border-accent);
|
||||||
|
background: rgba(0, 200, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-embedding-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-bright);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-progress-bar {
|
||||||
|
width: 120px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s var(--ease-out);
|
||||||
|
min-width: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-embedding-banner .btn-sm {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-embedding-banner .btn-sm:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-embedding-banner .btn-sm:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 240px);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty p {
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
border: 1px solid var(--border-accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-role {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sources {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sources-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-location {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-snippet {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-source-snippet code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
resize: none;
|
||||||
|
min-height: 42px;
|
||||||
|
max-height: 120px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s var(--ease-out);
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SBOM Enhancements ── */
|
||||||
|
|
||||||
|
.sbom-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-select {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s var(--ease-out);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-input {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 200px;
|
||||||
|
transition: border-color 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-filter-input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-result-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export */
|
||||||
|
.sbom-export-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-export-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-export-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-bright);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-export-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-export-result {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-export-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vulnerability drill-down */
|
||||||
|
.sbom-vuln-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-detail-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-detail {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 240px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-source {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-vuln-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* License compliance */
|
||||||
|
.sbom-license-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-permissive {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid rgba(0, 230, 118, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-weak-copyleft {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid rgba(255, 176, 32, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-copyleft {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(255, 59, 92, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-copyleft-warning {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border: 1px solid rgba(255, 59, 92, 0.3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-copyleft-warning strong {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 15px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-copyleft-warning p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-copyleft-item {
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-pkg-list {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: width 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar.license-permissive {
|
||||||
|
background: var(--success);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar.license-copyleft {
|
||||||
|
background: var(--danger);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-bar-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SBOM Diff */
|
||||||
|
.sbom-diff-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-select-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-select-group label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-stat {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-stat-num {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-added .sbom-diff-stat-num {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-removed .sbom-diff-stat-num {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-changed .sbom-diff-stat-num {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-row-added {
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-row-removed {
|
||||||
|
border-left: 3px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sbom-diff-row-changed {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub enum Route {
|
|||||||
GraphExplorerPage { repo_id: String },
|
GraphExplorerPage { repo_id: String },
|
||||||
#[route("/graph/:repo_id/impact/:finding_id")]
|
#[route("/graph/:repo_id/impact/:finding_id")]
|
||||||
ImpactAnalysisPage { repo_id: String, finding_id: String },
|
ImpactAnalysisPage { repo_id: String, finding_id: String },
|
||||||
|
#[route("/chat")]
|
||||||
|
ChatIndexPage {},
|
||||||
|
#[route("/chat/:repo_id")]
|
||||||
|
ChatPage { repo_id: String },
|
||||||
#[route("/dast")]
|
#[route("/dast")]
|
||||||
DastOverviewPage {},
|
DastOverviewPage {},
|
||||||
#[route("/dast/targets")]
|
#[route("/dast/targets")]
|
||||||
|
|||||||
@@ -47,17 +47,19 @@ fn insert_path(
|
|||||||
let name = parts[0].to_string();
|
let name = parts[0].to_string();
|
||||||
let is_leaf = parts.len() == 1;
|
let is_leaf = parts.len() == 1;
|
||||||
|
|
||||||
let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode {
|
let entry = children
|
||||||
name: name.clone(),
|
.entry(name.clone())
|
||||||
path: if is_leaf {
|
.or_insert_with(|| FileTreeNode {
|
||||||
full_path.to_string()
|
name: name.clone(),
|
||||||
} else {
|
path: if is_leaf {
|
||||||
String::new()
|
full_path.to_string()
|
||||||
},
|
} else {
|
||||||
is_dir: !is_leaf,
|
String::new()
|
||||||
node_count: 0,
|
},
|
||||||
children: Vec::new(),
|
is_dir: !is_leaf,
|
||||||
});
|
node_count: 0,
|
||||||
|
children: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
if is_leaf {
|
if is_leaf {
|
||||||
entry.node_count = node_count;
|
entry.node_count = node_count;
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub fn Sidebar() -> Element {
|
|||||||
route: Route::GraphIndexPage {},
|
route: Route::GraphIndexPage {},
|
||||||
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
|
NavItem {
|
||||||
|
label: "AI Chat",
|
||||||
|
route: Route::ChatIndexPage {},
|
||||||
|
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||||
|
},
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "DAST",
|
label: "DAST",
|
||||||
route: Route::DastOverviewPage {},
|
route: Route::DastOverviewPage {},
|
||||||
@@ -58,7 +63,11 @@ pub fn Sidebar() -> Element {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
|
let sidebar_class = if collapsed() {
|
||||||
|
"sidebar collapsed"
|
||||||
|
} else {
|
||||||
|
"sidebar"
|
||||||
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
nav { class: "{sidebar_class}",
|
nav { class: "{sidebar_class}",
|
||||||
@@ -76,6 +85,7 @@ pub fn Sidebar() -> Element {
|
|||||||
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
|
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
|
||||||
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
|
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
|
||||||
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
|
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
|
||||||
|
(Route::ChatPage { .. }, Route::ChatIndexPage {}) => true,
|
||||||
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub struct Toasts {
|
|||||||
next_id: Signal<usize>,
|
next_id: Signal<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Toasts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Toasts {
|
impl Toasts {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -39,11 +45,11 @@ impl Toasts {
|
|||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
{
|
{
|
||||||
let mut items = self.items;
|
let mut items = self.items;
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
||||||
items.write().retain(|t| t.id != id);
|
items.write().retain(|t| t.id != id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ── Response types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ChatApiResponse {
|
||||||
|
pub data: ChatResponseData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ChatResponseData {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<SourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SourceRef {
|
||||||
|
pub file_path: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub snippet: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct EmbeddingStatusResponse {
|
||||||
|
pub data: Option<EmbeddingBuildData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct EmbeddingBuildData {
|
||||||
|
pub repo_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub total_chunks: u32,
|
||||||
|
pub embedded_chunks: u32,
|
||||||
|
pub embedding_model: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub started_at: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub completed_at: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat message history type ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatHistoryMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server functions ──
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn send_chat_message(
|
||||||
|
repo_id: String,
|
||||||
|
message: String,
|
||||||
|
history: Vec<ChatHistoryMessage>,
|
||||||
|
) -> Result<ChatApiResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"message": message,
|
||||||
|
"history": history,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
|
||||||
|
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?;
|
||||||
|
|
||||||
|
let body: ChatApiResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse response: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/chat/{repo_id}/build-embeddings",
|
||||||
|
state.agent_api_url
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.post(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_embedding_status(
|
||||||
|
repo_id: String,
|
||||||
|
) -> Result<EmbeddingStatusResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url);
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: EmbeddingStatusResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
@@ -87,10 +87,7 @@ pub async fn fetch_dast_finding_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn add_dast_target(
|
pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> {
|
||||||
name: String,
|
|
||||||
base_url: String,
|
|
||||||
) -> Result<(), ServerFnError> {
|
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
||||||
|
|||||||
@@ -121,10 +121,7 @@ pub async fn fetch_file_content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn search_nodes(
|
pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchResponse, ServerFnError> {
|
||||||
repo_id: String,
|
|
||||||
query: String,
|
|
||||||
) -> Result<SearchResponse, ServerFnError> {
|
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!(
|
let url = format!(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Server function modules (compiled for both web and server;
|
// Server function modules (compiled for both web and server;
|
||||||
// the #[server] macro generates client stubs for the web target)
|
// the #[server] macro generates client stubs for the web target)
|
||||||
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod findings;
|
pub mod findings;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
|
|||||||
@@ -61,6 +61,29 @@ pub async fn add_repository(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Failed to delete repository: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
|
|||||||
@@ -1,27 +1,202 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use compliance_core::models::SbomEntry;
|
// ── Local types (no bson dependency, WASM-safe) ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct VulnRefData {
|
||||||
|
pub id: String,
|
||||||
|
pub source: String,
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomEntryData {
|
||||||
|
#[serde(rename = "_id", default)]
|
||||||
|
pub id: Option<serde_json::Value>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub license: Option<String>,
|
||||||
|
pub purl: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub known_vulnerabilities: Vec<VulnRefData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SbomListResponse {
|
pub struct SbomListResponse {
|
||||||
pub data: Vec<SbomEntry>,
|
pub data: Vec<SbomEntryData>,
|
||||||
pub total: Option<u64>,
|
pub total: Option<u64>,
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryData {
|
||||||
|
pub license: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub is_copyleft: bool,
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryResponse {
|
||||||
|
pub data: Vec<LicenseSummaryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffEntryData {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomVersionDiffData {
|
||||||
|
pub name: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub version_a: String,
|
||||||
|
pub version_b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffResultData {
|
||||||
|
pub only_in_a: Vec<SbomDiffEntryData>,
|
||||||
|
pub only_in_b: Vec<SbomDiffEntryData>,
|
||||||
|
pub version_changed: Vec<SbomVersionDiffData>,
|
||||||
|
pub common_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffResponse {
|
||||||
|
pub data: SbomDiffResultData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server functions ──
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn fetch_sbom(page: u64) -> Result<SbomListResponse, ServerFnError> {
|
pub async fn fetch_sbom_filtered(
|
||||||
|
repo_id: Option<String>,
|
||||||
|
package_manager: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
has_vulns: Option<bool>,
|
||||||
|
license: Option<String>,
|
||||||
|
page: u64,
|
||||||
|
) -> Result<SbomListResponse, ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url);
|
|
||||||
|
let mut params = vec![format!("page={page}"), "limit=50".to_string()];
|
||||||
|
if let Some(r) = &repo_id {
|
||||||
|
if !r.is_empty() {
|
||||||
|
params.push(format!("repo_id={r}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pm) = &package_manager {
|
||||||
|
if !pm.is_empty() {
|
||||||
|
params.push(format!("package_manager={pm}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(q) = &q {
|
||||||
|
if !q.is_empty() {
|
||||||
|
params.push(format!("q={}", q.replace(' ', "%20")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(hv) = has_vulns {
|
||||||
|
params.push(format!("has_vulns={hv}"));
|
||||||
|
}
|
||||||
|
if let Some(l) = &license {
|
||||||
|
if !l.is_empty() {
|
||||||
|
params.push(format!("license={}", l.replace(' ', "%20")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&"));
|
||||||
|
|
||||||
let resp = reqwest::get(&url)
|
let resp = reqwest::get(&url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
let body: SbomListResponse = resp
|
let text = resp
|
||||||
.json()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: SbomListResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result<String, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/sbom/export?repo_id={}&format={}",
|
||||||
|
state.agent_api_url, repo_id, format
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_license_summary(
|
||||||
|
repo_id: Option<String>,
|
||||||
|
) -> Result<LicenseSummaryResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let mut url = format!("{}/api/v1/sbom/licenses", state.agent_api_url);
|
||||||
|
if let Some(r) = &repo_id {
|
||||||
|
if !r.is_empty() {
|
||||||
|
url = format!("{url}?repo_id={r}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: LicenseSummaryResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_sbom_diff(
|
||||||
|
repo_a: String,
|
||||||
|
repo_b: String,
|
||||||
|
) -> Result<SbomDiffResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/sbom/diff?repo_a={}&repo_b={}",
|
||||||
|
state.agent_api_url, repo_a, repo_b
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: SbomDiffResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|||||||
288
compliance-dashboard/src/pages/chat.rs
Normal file
288
compliance-dashboard/src/pages/chat.rs
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::page_header::PageHeader;
|
||||||
|
use crate::infrastructure::chat::{
|
||||||
|
fetch_embedding_status, send_chat_message, trigger_embedding_build, ChatHistoryMessage,
|
||||||
|
SourceRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A UI-level chat message
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct UiChatMessage {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
sources: Vec<SourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ChatPage(repo_id: String) -> Element {
|
||||||
|
let mut messages: Signal<Vec<UiChatMessage>> = use_signal(Vec::new);
|
||||||
|
let mut input_text = use_signal(String::new);
|
||||||
|
let mut loading = use_signal(|| false);
|
||||||
|
let mut building = use_signal(|| false);
|
||||||
|
|
||||||
|
let repo_id_for_status = repo_id.clone();
|
||||||
|
let mut embedding_status = use_resource(move || {
|
||||||
|
let rid = repo_id_for_status.clone();
|
||||||
|
async move { fetch_embedding_status(rid).await.ok() }
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_embeddings = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.status == "completed")
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_running = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.status == "running")
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let embed_progress = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| {
|
||||||
|
if d.total_chunks > 0 {
|
||||||
|
(d.embedded_chunks as f64 / d.total_chunks as f64 * 100.0) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(0),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let embedding_status_text = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => match &resp.data {
|
||||||
|
Some(d) => match d.status.as_str() {
|
||||||
|
"completed" => format!(
|
||||||
|
"Embeddings ready: {}/{} chunks",
|
||||||
|
d.embedded_chunks, d.total_chunks
|
||||||
|
),
|
||||||
|
"running" => format!(
|
||||||
|
"Building embeddings: {}/{} chunks ({}%)",
|
||||||
|
d.embedded_chunks, d.total_chunks, embed_progress
|
||||||
|
),
|
||||||
|
"failed" => format!(
|
||||||
|
"Embedding build failed: {}",
|
||||||
|
d.error_message.as_deref().unwrap_or("unknown error")
|
||||||
|
),
|
||||||
|
s => format!("Status: {s}"),
|
||||||
|
},
|
||||||
|
None => "No embeddings built yet".to_string(),
|
||||||
|
},
|
||||||
|
Some(None) => "Failed to check embedding status".to_string(),
|
||||||
|
None => "Checking embedding status...".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-poll embedding status every 3s while building/running
|
||||||
|
use_effect(move || {
|
||||||
|
if is_running || *building.read() {
|
||||||
|
spawn(async move {
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
gloo_timers::future::TimeoutFuture::new(3_000).await;
|
||||||
|
#[cfg(not(feature = "web"))]
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
embedding_status.restart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let repo_id_for_build = repo_id.clone();
|
||||||
|
let on_build = move |_| {
|
||||||
|
let rid = repo_id_for_build.clone();
|
||||||
|
building.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
let _ = trigger_embedding_build(rid).await;
|
||||||
|
building.set(false);
|
||||||
|
embedding_status.restart();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo_id_for_send = repo_id.clone();
|
||||||
|
let mut do_send = move || {
|
||||||
|
let text = input_text.read().trim().to_string();
|
||||||
|
if text.is_empty() || *loading.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rid = repo_id_for_send.clone();
|
||||||
|
let user_msg = text.clone();
|
||||||
|
|
||||||
|
// Add user message to UI
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: user_msg.clone(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
});
|
||||||
|
input_text.set(String::new());
|
||||||
|
loading.set(true);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
// Build history from existing messages
|
||||||
|
let history: Vec<ChatHistoryMessage> = messages
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "user" || m.role == "assistant")
|
||||||
|
.rev()
|
||||||
|
.skip(1) // skip the message we just added
|
||||||
|
.take(10) // limit history
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|m| ChatHistoryMessage {
|
||||||
|
role: m.role.clone(),
|
||||||
|
content: m.content.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match send_chat_message(rid, user_msg, history).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: resp.data.message,
|
||||||
|
sources: resp.data.sources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: format!("Error: {e}"),
|
||||||
|
sources: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut do_send_click = do_send.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
PageHeader { title: "AI Chat" }
|
||||||
|
|
||||||
|
// Embedding status banner
|
||||||
|
div { class: if is_running || *building.read() { "chat-embedding-banner chat-embedding-building" } else { "chat-embedding-banner" },
|
||||||
|
div { class: "chat-embedding-status",
|
||||||
|
if is_running || *building.read() {
|
||||||
|
span { class: "chat-spinner" }
|
||||||
|
}
|
||||||
|
span { "{embedding_status_text}" }
|
||||||
|
}
|
||||||
|
if is_running || *building.read() {
|
||||||
|
div { class: "chat-progress-bar",
|
||||||
|
div {
|
||||||
|
class: "chat-progress-fill",
|
||||||
|
style: "width: {embed_progress}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm",
|
||||||
|
disabled: *building.read() || is_running,
|
||||||
|
onclick: on_build,
|
||||||
|
if *building.read() || is_running { "Building..." } else { "Build Embeddings" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "chat-container",
|
||||||
|
// Message list
|
||||||
|
div { class: "chat-messages",
|
||||||
|
if messages.read().is_empty() && !*loading.read() {
|
||||||
|
div { class: "chat-empty",
|
||||||
|
h3 { "Ask anything about your codebase" }
|
||||||
|
p { "Build embeddings first, then ask questions about functions, architecture, patterns, and more." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, msg) in messages.read().iter().enumerate() {
|
||||||
|
{
|
||||||
|
let class = if msg.role == "user" {
|
||||||
|
"chat-message chat-message-user"
|
||||||
|
} else {
|
||||||
|
"chat-message chat-message-assistant"
|
||||||
|
};
|
||||||
|
let content = msg.content.clone();
|
||||||
|
let sources = msg.sources.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: class, key: "{i}",
|
||||||
|
div { class: "chat-message-role",
|
||||||
|
if msg.role == "user" { "You" } else { "Assistant" }
|
||||||
|
}
|
||||||
|
div { class: "chat-message-content", "{content}" }
|
||||||
|
if !sources.is_empty() {
|
||||||
|
div { class: "chat-sources",
|
||||||
|
span { class: "chat-sources-label", "Sources:" }
|
||||||
|
for src in sources {
|
||||||
|
div { class: "chat-source-card",
|
||||||
|
div { class: "chat-source-header",
|
||||||
|
span { class: "chat-source-name",
|
||||||
|
"{src.qualified_name}"
|
||||||
|
}
|
||||||
|
span { class: "chat-source-location",
|
||||||
|
"{src.file_path}:{src.start_line}-{src.end_line}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pre { class: "chat-source-snippet",
|
||||||
|
code { "{src.snippet}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *loading.read() {
|
||||||
|
div { class: "chat-message chat-message-assistant",
|
||||||
|
div { class: "chat-message-role", "Assistant" }
|
||||||
|
div { class: "chat-message-content chat-typing", "Thinking..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input area
|
||||||
|
div { class: "chat-input-area",
|
||||||
|
textarea {
|
||||||
|
class: "chat-input",
|
||||||
|
placeholder: "Ask about your codebase...",
|
||||||
|
value: "{input_text}",
|
||||||
|
disabled: !has_embeddings,
|
||||||
|
oninput: move |e| input_text.set(e.value()),
|
||||||
|
onkeydown: move |e: Event<KeyboardData>| {
|
||||||
|
if e.key() == Key::Enter && !e.modifiers().shift() {
|
||||||
|
e.prevent_default();
|
||||||
|
do_send();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn chat-send-btn",
|
||||||
|
disabled: *loading.read() || !has_embeddings,
|
||||||
|
onclick: move |_| do_send_click(),
|
||||||
|
"Send"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::components::page_header::PageHeader;
|
||||||
|
use crate::infrastructure::repositories::fetch_repositories;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ChatIndexPage() -> Element {
|
||||||
|
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
PageHeader {
|
||||||
|
title: "AI Chat",
|
||||||
|
description: "Ask questions about your codebase using RAG-augmented AI",
|
||||||
|
}
|
||||||
|
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(data)) => {
|
||||||
|
let repo_list = &data.data;
|
||||||
|
if repo_list.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
div { class: "card",
|
||||||
|
p { "No repositories found. Add a repository first." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {
|
||||||
|
div { class: "graph-index-grid",
|
||||||
|
for repo in repo_list {
|
||||||
|
{
|
||||||
|
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
let url = repo.git_url.clone();
|
||||||
|
let branch = repo.default_branch.clone();
|
||||||
|
rsx! {
|
||||||
|
Link {
|
||||||
|
to: Route::ChatPage { repo_id },
|
||||||
|
class: "graph-repo-card",
|
||||||
|
div { class: "graph-repo-card-header",
|
||||||
|
div { class: "graph-repo-card-icon", "\u{1F4AC}" }
|
||||||
|
h3 { class: "graph-repo-card-name", "{name}" }
|
||||||
|
}
|
||||||
|
if !url.is_empty() {
|
||||||
|
p { class: "graph-repo-card-url", "{url}" }
|
||||||
|
}
|
||||||
|
div { class: "graph-repo-card-meta",
|
||||||
|
span { class: "graph-repo-card-tag",
|
||||||
|
"\u{E0A0} {branch}"
|
||||||
|
}
|
||||||
|
span { class: "graph-repo-card-tag",
|
||||||
|
"AI Chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load repositories." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading repositories..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ pub fn DastFindingsPage() -> Element {
|
|||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
Link {
|
Link {
|
||||||
to: Route::DastFindingDetailPage { id: id },
|
to: Route::DastFindingDetailPage { id },
|
||||||
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ pub fn FindingsPage() -> Element {
|
|||||||
let mut repo_filter = use_signal(String::new);
|
let mut repo_filter = use_signal(String::new);
|
||||||
|
|
||||||
let repos = use_resource(|| async {
|
let repos = use_resource(|| async {
|
||||||
crate::infrastructure::repositories::fetch_repositories(1).await.ok()
|
crate::infrastructure::repositories::fetch_repositories(1)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
});
|
});
|
||||||
|
|
||||||
let findings = use_resource(move || {
|
let findings = use_resource(move || {
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
let mut inspector_open = use_signal(|| false);
|
let mut inspector_open = use_signal(|| false);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
let mut search_query = use_signal(|| String::new());
|
let mut search_query = use_signal(String::new);
|
||||||
let mut search_results = use_signal(|| Vec::<serde_json::Value>::new());
|
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
||||||
let mut file_filter = use_signal(|| String::new());
|
let mut file_filter = use_signal(String::new);
|
||||||
|
|
||||||
// Store serialized graph JSON in signals so use_effect can react to them
|
// Store serialized graph JSON in signals so use_effect can react to them
|
||||||
let mut nodes_json = use_signal(|| String::new());
|
let mut nodes_json = use_signal(String::new);
|
||||||
let mut edges_json = use_signal(|| String::new());
|
let mut edges_json = use_signal(String::new);
|
||||||
let mut graph_ready = use_signal(|| false);
|
let mut graph_ready = use_signal(|| false);
|
||||||
|
|
||||||
// When resource resolves, serialize the data into signals
|
// When resource resolves, serialize the data into signals
|
||||||
@@ -404,7 +404,7 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
} else if node_count > 0 {
|
} else if node_count > 0 {
|
||||||
// Data exists but nodes array was empty (shouldn't happen)
|
// Data exists but nodes array was empty (shouldn't happen)
|
||||||
div { class: "loading", "Loading graph visualization..." }
|
div { class: "loading", "Loading graph visualization..." }
|
||||||
} else if matches!(&*graph_data.read(), None) {
|
} else if (*graph_data.read()).is_none() {
|
||||||
div { class: "loading", "Loading graph data..." }
|
div { class: "loading", "Loading graph data..." }
|
||||||
} else {
|
} else {
|
||||||
div { class: "graph-empty-state",
|
div { class: "graph-empty-state",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod chat;
|
||||||
|
pub mod chat_index;
|
||||||
pub mod dast_finding_detail;
|
pub mod dast_finding_detail;
|
||||||
pub mod dast_findings;
|
pub mod dast_findings;
|
||||||
pub mod dast_overview;
|
pub mod dast_overview;
|
||||||
@@ -13,6 +15,8 @@ pub mod repositories;
|
|||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
|
pub use chat::ChatPage;
|
||||||
|
pub use chat_index::ChatIndexPage;
|
||||||
pub use dast_finding_detail::DastFindingDetailPage;
|
pub use dast_finding_detail::DastFindingDetailPage;
|
||||||
pub use dast_findings::DastFindingsPage;
|
pub use dast_findings::DastFindingsPage;
|
||||||
pub use dast_overview::DastOverviewPage;
|
pub use dast_overview::DastOverviewPage;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
let mut git_url = use_signal(String::new);
|
let mut git_url = use_signal(String::new);
|
||||||
let mut branch = use_signal(|| "main".to_string());
|
let mut branch = use_signal(|| "main".to_string());
|
||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||||
|
|
||||||
let mut repos = use_resource(move || {
|
let mut repos = use_resource(move || {
|
||||||
let p = page();
|
let p = page();
|
||||||
@@ -91,6 +92,48 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog ──
|
||||||
|
if let Some((del_id, del_name)) = confirm_delete() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
div { class: "modal-dialog",
|
||||||
|
h3 { "Delete Repository" }
|
||||||
|
p {
|
||||||
|
"Are you sure you want to delete "
|
||||||
|
strong { "{del_name}" }
|
||||||
|
"?"
|
||||||
|
}
|
||||||
|
p { class: "modal-warning",
|
||||||
|
"This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts."
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
let id = del_id.clone();
|
||||||
|
let name = del_name.clone();
|
||||||
|
confirm_delete.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match crate::infrastructure::repositories::delete_repository(id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, format!("{name} deleted"));
|
||||||
|
repos.restart();
|
||||||
|
}
|
||||||
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match &*repos.read() {
|
match &*repos.read() {
|
||||||
Some(Some(resp)) => {
|
Some(Some(resp)) => {
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||||
@@ -112,7 +155,9 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
for repo in &resp.data {
|
for repo in &resp.data {
|
||||||
{
|
{
|
||||||
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
let repo_id_clone = repo_id.clone();
|
let repo_id_scan = repo_id.clone();
|
||||||
|
let repo_id_del = repo_id.clone();
|
||||||
|
let repo_name_del = repo.name.clone();
|
||||||
rsx! {
|
rsx! {
|
||||||
tr {
|
tr {
|
||||||
td { "{repo.name}" }
|
td { "{repo.name}" }
|
||||||
@@ -149,7 +194,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
button {
|
button {
|
||||||
class: "btn btn-ghost",
|
class: "btn btn-ghost",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let id = repo_id_clone.clone();
|
let id = repo_id_scan.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||||
@@ -159,6 +204,13 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
},
|
},
|
||||||
"Scan"
|
"Scan"
|
||||||
}
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-ghost-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone())));
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,60 +2,335 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::pagination::Pagination;
|
use crate::components::pagination::Pagination;
|
||||||
|
use crate::infrastructure::sbom::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SbomPage() -> Element {
|
pub fn SbomPage() -> Element {
|
||||||
|
// ── Filter signals ──
|
||||||
let mut page = use_signal(|| 1u64);
|
let mut page = use_signal(|| 1u64);
|
||||||
|
let mut repo_filter = use_signal(String::new);
|
||||||
|
let mut pm_filter = use_signal(String::new);
|
||||||
|
let mut search_q = use_signal(String::new);
|
||||||
|
let mut vuln_toggle = use_signal(|| Option::<bool>::None);
|
||||||
|
let mut license_filter = use_signal(String::new);
|
||||||
|
|
||||||
|
// ── Active tab: "packages" | "licenses" | "diff" ──
|
||||||
|
let mut active_tab = use_signal(|| "packages".to_string());
|
||||||
|
|
||||||
|
// ── Vuln drill-down: track expanded row by (name, version) ──
|
||||||
|
let mut expanded_row = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// ── Export state ──
|
||||||
|
let mut show_export = use_signal(|| false);
|
||||||
|
let mut export_format = use_signal(|| "cyclonedx".to_string());
|
||||||
|
let mut export_result = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// ── Diff state ──
|
||||||
|
let mut diff_repo_a = use_signal(String::new);
|
||||||
|
let mut diff_repo_b = use_signal(String::new);
|
||||||
|
|
||||||
|
// ── Repos for dropdowns ──
|
||||||
|
let repos = use_resource(|| async {
|
||||||
|
crate::infrastructure::repositories::fetch_repositories(1)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SBOM list (filtered) ──
|
||||||
let sbom = use_resource(move || {
|
let sbom = use_resource(move || {
|
||||||
let p = page();
|
let p = page();
|
||||||
async move { crate::infrastructure::sbom::fetch_sbom(p).await.ok() }
|
let repo = repo_filter();
|
||||||
|
let pm = pm_filter();
|
||||||
|
let q = search_q();
|
||||||
|
let hv = vuln_toggle();
|
||||||
|
let lic = license_filter();
|
||||||
|
async move {
|
||||||
|
fetch_sbom_filtered(
|
||||||
|
if repo.is_empty() { None } else { Some(repo) },
|
||||||
|
if pm.is_empty() { None } else { Some(pm) },
|
||||||
|
if q.is_empty() { None } else { Some(q) },
|
||||||
|
hv,
|
||||||
|
if lic.is_empty() { None } else { Some(lic) },
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── License summary ──
|
||||||
|
let license_data = use_resource(move || {
|
||||||
|
let repo = repo_filter();
|
||||||
|
async move {
|
||||||
|
fetch_license_summary(if repo.is_empty() { None } else { Some(repo) })
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Diff data ──
|
||||||
|
let diff_data = use_resource(move || {
|
||||||
|
let a = diff_repo_a();
|
||||||
|
let b = diff_repo_b();
|
||||||
|
async move {
|
||||||
|
if a.is_empty() || b.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
fetch_sbom_diff(a, b).await.ok()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "SBOM",
|
title: "SBOM",
|
||||||
description: "Software Bill of Materials - dependency inventory across all repositories",
|
description: "Software Bill of Materials — dependency inventory, license compliance, and vulnerability analysis",
|
||||||
}
|
}
|
||||||
|
|
||||||
match &*sbom.read() {
|
// ── Tab bar ──
|
||||||
Some(Some(resp)) => {
|
div { class: "sbom-tab-bar",
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
button {
|
||||||
rsx! {
|
class: if active_tab() == "packages" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
div { class: "card",
|
onclick: move |_| active_tab.set("packages".to_string()),
|
||||||
div { class: "table-wrapper",
|
"Packages"
|
||||||
table {
|
}
|
||||||
thead {
|
button {
|
||||||
tr {
|
class: if active_tab() == "licenses" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
th { "Package" }
|
onclick: move |_| active_tab.set("licenses".to_string()),
|
||||||
th { "Version" }
|
"License Compliance"
|
||||||
th { "Manager" }
|
}
|
||||||
th { "License" }
|
button {
|
||||||
th { "Vulnerabilities" }
|
class: if active_tab() == "diff" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
|
onclick: move |_| active_tab.set("diff".to_string()),
|
||||||
|
"Compare"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ PACKAGES TAB ═══════════════
|
||||||
|
if active_tab() == "packages" {
|
||||||
|
// ── Filter bar ──
|
||||||
|
div { class: "sbom-filter-bar",
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Repositories" }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tbody {
|
},
|
||||||
for entry in &resp.data {
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { pm_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Managers" }
|
||||||
|
option { value: "npm", "npm" }
|
||||||
|
option { value: "cargo", "Cargo" }
|
||||||
|
option { value: "pip", "pip" }
|
||||||
|
option { value: "go", "Go" }
|
||||||
|
option { value: "maven", "Maven" }
|
||||||
|
option { value: "nuget", "NuGet" }
|
||||||
|
option { value: "composer", "Composer" }
|
||||||
|
option { value: "gem", "RubyGems" }
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
class: "sbom-filter-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Search packages...",
|
||||||
|
oninput: move |e| { search_q.set(e.value()); page.set(1); },
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| {
|
||||||
|
let val = e.value();
|
||||||
|
vuln_toggle.set(match val.as_str() {
|
||||||
|
"true" => Some(true),
|
||||||
|
"false" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
page.set(1);
|
||||||
|
},
|
||||||
|
option { value: "", "All Packages" }
|
||||||
|
option { value: "true", "With Vulnerabilities" }
|
||||||
|
option { value: "false", "No Vulnerabilities" }
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { license_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Licenses" }
|
||||||
|
option { value: "MIT", "MIT" }
|
||||||
|
option { value: "Apache-2.0", "Apache 2.0" }
|
||||||
|
option { value: "BSD-3-Clause", "BSD 3-Clause" }
|
||||||
|
option { value: "ISC", "ISC" }
|
||||||
|
option { value: "GPL-3.0", "GPL 3.0" }
|
||||||
|
option { value: "GPL-2.0", "GPL 2.0" }
|
||||||
|
option { value: "LGPL-2.1", "LGPL 2.1" }
|
||||||
|
option { value: "MPL-2.0", "MPL 2.0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export button ──
|
||||||
|
div { class: "sbom-export-wrapper",
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary sbom-export-btn",
|
||||||
|
onclick: move |_| show_export.toggle(),
|
||||||
|
"Export"
|
||||||
|
}
|
||||||
|
if show_export() {
|
||||||
|
div { class: "sbom-export-dropdown",
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
value: "{export_format}",
|
||||||
|
onchange: move |e| export_format.set(e.value()),
|
||||||
|
option { value: "cyclonedx", "CycloneDX 1.5" }
|
||||||
|
option { value: "spdx", "SPDX 2.3" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
disabled: repo_filter().is_empty(),
|
||||||
|
onclick: move |_| {
|
||||||
|
let repo = repo_filter();
|
||||||
|
let fmt = export_format();
|
||||||
|
spawn(async move {
|
||||||
|
match fetch_sbom_export(repo, fmt).await {
|
||||||
|
Ok(json) => export_result.set(Some(json)),
|
||||||
|
Err(e) => tracing::error!("Export failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Download"
|
||||||
|
}
|
||||||
|
if repo_filter().is_empty() {
|
||||||
|
span { class: "sbom-export-hint", "Select a repo first" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export result display ──
|
||||||
|
if let Some(json) = export_result() {
|
||||||
|
div { class: "card sbom-export-result",
|
||||||
|
div { class: "sbom-export-result-header",
|
||||||
|
strong { "Exported SBOM" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| export_result.set(None),
|
||||||
|
"Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
style: "max-height: 400px; overflow: auto; font-size: 12px;",
|
||||||
|
"{json}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SBOM table ──
|
||||||
|
match &*sbom.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
||||||
|
rsx! {
|
||||||
|
if let Some(total) = resp.total {
|
||||||
|
div { class: "sbom-result-count",
|
||||||
|
"{total} package(s) found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
tr {
|
tr {
|
||||||
td {
|
th { "Package" }
|
||||||
style: "font-weight: 500;",
|
th { "Version" }
|
||||||
"{entry.name}"
|
th { "Manager" }
|
||||||
}
|
th { "License" }
|
||||||
td {
|
th { "Vulnerabilities" }
|
||||||
style: "font-family: monospace; font-size: 13px;",
|
}
|
||||||
"{entry.version}"
|
}
|
||||||
}
|
tbody {
|
||||||
td { "{entry.package_manager}" }
|
for entry in &resp.data {
|
||||||
td { "{entry.license.as_deref().unwrap_or(\"-\")}" }
|
{
|
||||||
td {
|
let row_key = format!("{}@{}", entry.name, entry.version);
|
||||||
if entry.known_vulnerabilities.is_empty() {
|
let is_expanded = expanded_row() == Some(row_key.clone());
|
||||||
span {
|
let has_vulns = !entry.known_vulnerabilities.is_empty();
|
||||||
style: "color: var(--success);",
|
let license_class = license_css_class(entry.license.as_deref());
|
||||||
"None"
|
let row_key_click = row_key.clone();
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
style: "font-weight: 500;",
|
||||||
|
"{entry.name}"
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
style: "font-family: var(--font-mono, monospace); font-size: 13px;",
|
||||||
|
"{entry.version}"
|
||||||
|
}
|
||||||
|
td { "{entry.package_manager}" }
|
||||||
|
td {
|
||||||
|
span { class: "sbom-license-badge {license_class}",
|
||||||
|
"{entry.license.as_deref().unwrap_or(\"-\")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if has_vulns {
|
||||||
|
span {
|
||||||
|
class: "badge badge-high sbom-vuln-toggle",
|
||||||
|
onclick: move |_| {
|
||||||
|
let key = row_key_click.clone();
|
||||||
|
if expanded_row() == Some(key.clone()) {
|
||||||
|
expanded_row.set(None);
|
||||||
|
} else {
|
||||||
|
expanded_row.set(Some(key));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{entry.known_vulnerabilities.len()} vuln(s) ▾"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span {
|
||||||
|
style: "color: var(--success);",
|
||||||
|
"None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
// ── Vulnerability drill-down row ──
|
||||||
span { class: "badge badge-high",
|
if is_expanded && has_vulns {
|
||||||
"{entry.known_vulnerabilities.len()} vuln(s)"
|
tr { class: "sbom-vuln-detail-row",
|
||||||
|
td { colspan: "5",
|
||||||
|
div { class: "sbom-vuln-detail",
|
||||||
|
for vuln in &entry.known_vulnerabilities {
|
||||||
|
div { class: "sbom-vuln-card",
|
||||||
|
div { class: "sbom-vuln-card-header",
|
||||||
|
span { class: "sbom-vuln-id", "{vuln.id}" }
|
||||||
|
span { class: "sbom-vuln-source", "{vuln.source}" }
|
||||||
|
if let Some(sev) = &vuln.severity {
|
||||||
|
span {
|
||||||
|
class: "badge badge-{sev}",
|
||||||
|
"{sev}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(url) = &vuln.url {
|
||||||
|
a {
|
||||||
|
href: "{url}",
|
||||||
|
target: "_blank",
|
||||||
|
class: "sbom-vuln-link",
|
||||||
|
"View Advisory →"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,21 +338,321 @@ pub fn SbomPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Pagination {
|
||||||
|
current_page: page(),
|
||||||
|
total_pages: total_pages,
|
||||||
|
on_page_change: move |p| page.set(p),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Pagination {
|
}
|
||||||
current_page: page(),
|
},
|
||||||
total_pages: total_pages,
|
Some(None) => rsx! {
|
||||||
on_page_change: move |p| page.set(p),
|
div { class: "card", p { "Failed to load SBOM." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading SBOM..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ LICENSE COMPLIANCE TAB ═══════════════
|
||||||
|
if active_tab() == "licenses" {
|
||||||
|
match &*license_data.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
let total_pkgs: u64 = resp.data.iter().map(|l| l.count).sum();
|
||||||
|
let has_copyleft = resp.data.iter().any(|l| l.is_copyleft);
|
||||||
|
let copyleft_items: Vec<_> = resp.data.iter().filter(|l| l.is_copyleft).collect();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
if has_copyleft {
|
||||||
|
div { class: "license-copyleft-warning",
|
||||||
|
strong { "⚠ Copyleft Licenses Detected" }
|
||||||
|
p { "The following copyleft-licensed packages may impose distribution requirements on your software." }
|
||||||
|
for item in ©left_items {
|
||||||
|
div { class: "license-copyleft-item",
|
||||||
|
span { class: "sbom-license-badge license-copyleft", "{item.license}" }
|
||||||
|
span { " — {item.count} package(s): " }
|
||||||
|
span { class: "license-pkg-list",
|
||||||
|
"{item.packages.join(\", \")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "License Distribution" }
|
||||||
|
if total_pkgs > 0 {
|
||||||
|
div { class: "license-bar-chart",
|
||||||
|
for item in &resp.data {
|
||||||
|
{
|
||||||
|
let pct = (item.count as f64 / total_pkgs as f64 * 100.0).max(2.0);
|
||||||
|
let bar_class = if item.is_copyleft { "license-bar license-copyleft" } else { "license-bar license-permissive" };
|
||||||
|
rsx! {
|
||||||
|
div { class: "license-bar-row",
|
||||||
|
span { class: "license-bar-label", "{item.license}" }
|
||||||
|
div { class: "license-bar-track",
|
||||||
|
div {
|
||||||
|
class: "{bar_class}",
|
||||||
|
style: "width: {pct}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "license-bar-count", "{item.count}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p { "No license data available." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "All Licenses" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "License" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Packages" }
|
||||||
|
th { "Count" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for item in &resp.data {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
span {
|
||||||
|
class: "sbom-license-badge {license_type_class(item.is_copyleft)}",
|
||||||
|
"{item.license}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if item.is_copyleft {
|
||||||
|
span { class: "badge badge-high", "Copyleft" }
|
||||||
|
} else {
|
||||||
|
span { class: "badge badge-info", "Permissive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
style: "max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
|
||||||
|
"{item.packages.join(\", \")}"
|
||||||
|
}
|
||||||
|
td { "{item.count}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load license summary." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading license data..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ DIFF TAB ═══════════════
|
||||||
|
if active_tab() == "diff" {
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "Compare SBOMs Between Repositories" }
|
||||||
|
div { class: "sbom-diff-controls",
|
||||||
|
div { class: "sbom-diff-select-group",
|
||||||
|
label { "Repository A" }
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| diff_repo_a.set(e.value()),
|
||||||
|
option { value: "", "Select repository..." }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-select-group",
|
||||||
|
label { "Repository B" }
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| diff_repo_b.set(e.value()),
|
||||||
|
option { value: "", "Select repository..." }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Some(None) => rsx! {
|
|
||||||
div { class: "card", p { "Failed to load SBOM." } }
|
if !diff_repo_a().is_empty() && !diff_repo_b().is_empty() {
|
||||||
},
|
match &*diff_data.read() {
|
||||||
None => rsx! {
|
Some(Some(resp)) => {
|
||||||
div { class: "loading", "Loading SBOM..." }
|
let d = &resp.data;
|
||||||
},
|
rsx! {
|
||||||
|
div { class: "sbom-diff-summary",
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-added",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.only_in_a.len()}" }
|
||||||
|
span { "Only in A" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-removed",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.only_in_b.len()}" }
|
||||||
|
span { "Only in B" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-changed",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.version_changed.len()}" }
|
||||||
|
span { "Version Diffs" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.common_count}" }
|
||||||
|
span { "Common" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.only_in_a.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--success);", "Only in Repository A" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Version" }
|
||||||
|
th { "Manager" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.only_in_a {
|
||||||
|
tr { class: "sbom-diff-row-added",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.version}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.only_in_b.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--danger);", "Only in Repository B" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Version" }
|
||||||
|
th { "Manager" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.only_in_b {
|
||||||
|
tr { class: "sbom-diff-row-removed",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.version}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.version_changed.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--warning);", "Version Differences" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Manager" }
|
||||||
|
th { "Version A" }
|
||||||
|
th { "Version B" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.version_changed {
|
||||||
|
tr { class: "sbom-diff-row-changed",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
td { "{e.version_a}" }
|
||||||
|
td { "{e.version_b}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.only_in_a.is_empty() && d.only_in_b.is_empty() && d.version_changed.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
p { "Both repositories have identical SBOM entries." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load diff." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Computing diff..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn license_css_class(license: Option<&str>) -> &'static str {
|
||||||
|
match license {
|
||||||
|
Some(l) => {
|
||||||
|
let upper = l.to_uppercase();
|
||||||
|
if upper.contains("GPL") || upper.contains("AGPL") {
|
||||||
|
"license-copyleft"
|
||||||
|
} else if upper.contains("LGPL") || upper.contains("MPL") {
|
||||||
|
"license-weak-copyleft"
|
||||||
|
} else {
|
||||||
|
"license-permissive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn license_type_class(is_copyleft: bool) -> &'static str {
|
||||||
|
if is_copyleft {
|
||||||
|
"license-copyleft"
|
||||||
|
} else {
|
||||||
|
"license-permissive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,10 +234,7 @@ impl ApiFuzzerAgent {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
let headers = response.headers();
|
let headers = response.headers();
|
||||||
let acao = headers
|
let acao = headers.get("access-control-allow-origin")?.to_str().ok()?;
|
||||||
.get("access-control-allow-origin")?
|
|
||||||
.to_str()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if acao == "*" || acao == "https://evil.com" {
|
if acao == "*" || acao == "https://evil.com" {
|
||||||
let acac = headers
|
let acac = headers
|
||||||
@@ -265,12 +262,9 @@ impl ApiFuzzerAgent {
|
|||||||
request_body: None,
|
request_body: None,
|
||||||
response_status: response.status().as_u16(),
|
response_status: response.status().as_u16(),
|
||||||
response_headers: Some(
|
response_headers: Some(
|
||||||
[(
|
[("Access-Control-Allow-Origin".to_string(), acao.to_string())]
|
||||||
"Access-Control-Allow-Origin".to_string(),
|
.into_iter()
|
||||||
acao.to_string(),
|
.collect(),
|
||||||
)]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
),
|
||||||
response_snippet: None,
|
response_snippet: None,
|
||||||
screenshot_path: None,
|
screenshot_path: None,
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ impl DastAgent for AuthBypassAgent {
|
|||||||
String::new(),
|
String::new(),
|
||||||
target_id.clone(),
|
target_id.clone(),
|
||||||
DastVulnType::AuthBypass,
|
DastVulnType::AuthBypass,
|
||||||
format!("HTTP method tampering: {} accepted on {}", method, endpoint.url),
|
format!(
|
||||||
|
"HTTP method tampering: {} accepted on {}",
|
||||||
|
method, endpoint.url
|
||||||
|
),
|
||||||
format!(
|
format!(
|
||||||
"Endpoint {} accepts {} requests which may bypass access controls.",
|
"Endpoint {} accepts {} requests which may bypass access controls.",
|
||||||
endpoint.url, method
|
endpoint.url, method
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ impl SsrfAgent {
|
|||||||
("http://[::1]", "localhost IPv6"),
|
("http://[::1]", "localhost IPv6"),
|
||||||
("http://0.0.0.0", "zero address"),
|
("http://0.0.0.0", "zero address"),
|
||||||
("http://169.254.169.254/latest/meta-data/", "AWS metadata"),
|
("http://169.254.169.254/latest/meta-data/", "AWS metadata"),
|
||||||
(
|
("http://metadata.google.internal/", "GCP metadata"),
|
||||||
"http://metadata.google.internal/",
|
|
||||||
"GCP metadata",
|
|
||||||
),
|
|
||||||
("http://127.0.0.1:22", "SSH port probe"),
|
("http://127.0.0.1:22", "SSH port probe"),
|
||||||
("http://127.0.0.1:3306", "MySQL port probe"),
|
("http://127.0.0.1:3306", "MySQL port probe"),
|
||||||
("http://localhost/admin", "localhost admin"),
|
("http://localhost/admin", "localhost admin"),
|
||||||
@@ -91,10 +88,7 @@ impl DastAgent for SsrfAgent {
|
|||||||
.post(&endpoint.url)
|
.post(&endpoint.url)
|
||||||
.form(&[(param.name.as_str(), payload)])
|
.form(&[(param.name.as_str(), payload)])
|
||||||
} else {
|
} else {
|
||||||
let test_url = format!(
|
let test_url = format!("{}?{}={}", endpoint.url, param.name, payload);
|
||||||
"{}?{}={}",
|
|
||||||
endpoint.url, param.name, payload
|
|
||||||
);
|
|
||||||
self.http.get(&test_url)
|
self.http.get(&test_url)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,10 +127,7 @@ impl DastAgent for SsrfAgent {
|
|||||||
String::new(),
|
String::new(),
|
||||||
target_id.clone(),
|
target_id.clone(),
|
||||||
DastVulnType::Ssrf,
|
DastVulnType::Ssrf,
|
||||||
format!(
|
format!("SSRF ({technique}) via parameter '{}'", param.name),
|
||||||
"SSRF ({technique}) via parameter '{}'",
|
|
||||||
param.name
|
|
||||||
),
|
|
||||||
format!(
|
format!(
|
||||||
"Server-side request forgery detected in parameter '{}' at {}. \
|
"Server-side request forgery detected in parameter '{}' at {}. \
|
||||||
The application made a request to an internal resource ({}).",
|
The application made a request to an internal resource ({}).",
|
||||||
|
|||||||
@@ -17,26 +17,11 @@ impl XssAgent {
|
|||||||
fn payloads(&self) -> Vec<(&str, &str)> {
|
fn payloads(&self) -> Vec<(&str, &str)> {
|
||||||
vec![
|
vec![
|
||||||
("<script>alert(1)</script>", "basic script injection"),
|
("<script>alert(1)</script>", "basic script injection"),
|
||||||
(
|
("<img src=x onerror=alert(1)>", "event handler injection"),
|
||||||
"<img src=x onerror=alert(1)>",
|
("<svg/onload=alert(1)>", "svg event handler"),
|
||||||
"event handler injection",
|
("javascript:alert(1)", "javascript protocol"),
|
||||||
),
|
("'\"><script>alert(1)</script>", "attribute breakout"),
|
||||||
(
|
("<body onload=alert(1)>", "body event handler"),
|
||||||
"<svg/onload=alert(1)>",
|
|
||||||
"svg event handler",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"javascript:alert(1)",
|
|
||||||
"javascript protocol",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"'\"><script>alert(1)</script>",
|
|
||||||
"attribute breakout",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"<body onload=alert(1)>",
|
|
||||||
"body event handler",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +50,7 @@ impl DastAgent for XssAgent {
|
|||||||
for param in &endpoint.parameters {
|
for param in &endpoint.parameters {
|
||||||
for (payload, technique) in self.payloads() {
|
for (payload, technique) in self.payloads() {
|
||||||
let test_url = if endpoint.method == "GET" {
|
let test_url = if endpoint.method == "GET" {
|
||||||
format!(
|
format!("{}?{}={}", endpoint.url, param.name, payload)
|
||||||
"{}?{}={}",
|
|
||||||
endpoint.url, param.name, payload
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
endpoint.url.clone()
|
endpoint.url.clone()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ impl WebCrawler {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
excluded_paths: &[String],
|
excluded_paths: &[String],
|
||||||
) -> Result<Vec<DiscoveredEndpoint>, CoreError> {
|
) -> Result<Vec<DiscoveredEndpoint>, CoreError> {
|
||||||
let base = Url::parse(base_url)
|
let base =
|
||||||
.map_err(|e| CoreError::Dast(format!("Invalid base URL: {e}")))?;
|
Url::parse(base_url).map_err(|e| CoreError::Dast(format!("Invalid base URL: {e}")))?;
|
||||||
|
|
||||||
let mut visited: HashSet<String> = HashSet::new();
|
let mut visited: HashSet<String> = HashSet::new();
|
||||||
let mut endpoints: Vec<DiscoveredEndpoint> = Vec::new();
|
let mut endpoints: Vec<DiscoveredEndpoint> = Vec::new();
|
||||||
@@ -95,12 +95,15 @@ impl WebCrawler {
|
|||||||
let document = Html::parse_document(&body);
|
let document = Html::parse_document(&body);
|
||||||
|
|
||||||
// Extract links
|
// Extract links
|
||||||
let link_selector =
|
let link_selector = match Selector::parse("a[href]") {
|
||||||
Selector::parse("a[href]").unwrap_or_else(|_| Selector::parse("a").expect("valid selector"));
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
for element in document.select(&link_selector) {
|
for element in document.select(&link_selector) {
|
||||||
if let Some(href) = element.value().attr("href") {
|
if let Some(href) = element.value().attr("href") {
|
||||||
if let Some(absolute_url) = self.resolve_url(&base, &url, href) {
|
if let Some(absolute_url) = self.resolve_url(&base, &url, href) {
|
||||||
if self.is_same_origin(&base, &absolute_url) && !visited.contains(&absolute_url)
|
if self.is_same_origin(&base, &absolute_url)
|
||||||
|
&& !visited.contains(&absolute_url)
|
||||||
{
|
{
|
||||||
queue.push((absolute_url, depth + 1));
|
queue.push((absolute_url, depth + 1));
|
||||||
}
|
}
|
||||||
@@ -109,18 +112,18 @@ impl WebCrawler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract forms
|
// Extract forms
|
||||||
let form_selector = Selector::parse("form")
|
let form_selector = match Selector::parse("form") {
|
||||||
.unwrap_or_else(|_| Selector::parse("form").expect("valid selector"));
|
Ok(s) => s,
|
||||||
let input_selector = Selector::parse("input, select, textarea")
|
Err(_) => continue,
|
||||||
.unwrap_or_else(|_| Selector::parse("input").expect("valid selector"));
|
};
|
||||||
|
let input_selector = match Selector::parse("input, select, textarea") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
for form in document.select(&form_selector) {
|
for form in document.select(&form_selector) {
|
||||||
let action = form.value().attr("action").unwrap_or("");
|
let action = form.value().attr("action").unwrap_or("");
|
||||||
let method = form
|
let method = form.value().attr("method").unwrap_or("GET").to_uppercase();
|
||||||
.value()
|
|
||||||
.attr("method")
|
|
||||||
.unwrap_or("GET")
|
|
||||||
.to_uppercase();
|
|
||||||
|
|
||||||
let form_url = self
|
let form_url = self
|
||||||
.resolve_url(&base, &url, action)
|
.resolve_url(&base, &url, action)
|
||||||
@@ -128,20 +131,12 @@ impl WebCrawler {
|
|||||||
|
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
for input in form.select(&input_selector) {
|
for input in form.select(&input_selector) {
|
||||||
let name = input
|
let name = input.value().attr("name").unwrap_or("").to_string();
|
||||||
.value()
|
|
||||||
.attr("name")
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let input_type = input
|
let input_type = input.value().attr("type").unwrap_or("text").to_string();
|
||||||
.value()
|
|
||||||
.attr("type")
|
|
||||||
.unwrap_or("text")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let location = if method == "GET" {
|
let location = if method == "GET" {
|
||||||
"query".to_string()
|
"query".to_string()
|
||||||
|
|||||||
@@ -149,11 +149,8 @@ impl DastOrchestrator {
|
|||||||
let t2 = target.clone();
|
let t2 = target.clone();
|
||||||
let c2 = context.clone();
|
let c2 = context.clone();
|
||||||
let h2 = http.clone();
|
let h2 = http.clone();
|
||||||
let xss_handle = tokio::spawn(async move {
|
let xss_handle =
|
||||||
crate::agents::xss::XssAgent::new(h2)
|
tokio::spawn(async move { crate::agents::xss::XssAgent::new(h2).run(&t2, &c2).await });
|
||||||
.run(&t2, &c2)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
let t3 = target.clone();
|
let t3 = target.clone();
|
||||||
let c3 = context.clone();
|
let c3 = context.clone();
|
||||||
@@ -167,11 +164,10 @@ impl DastOrchestrator {
|
|||||||
let t4 = target.clone();
|
let t4 = target.clone();
|
||||||
let c4 = context.clone();
|
let c4 = context.clone();
|
||||||
let h4 = http.clone();
|
let h4 = http.clone();
|
||||||
let ssrf_handle = tokio::spawn(async move {
|
let ssrf_handle =
|
||||||
crate::agents::ssrf::SsrfAgent::new(h4)
|
tokio::spawn(
|
||||||
.run(&t4, &c4)
|
async move { crate::agents::ssrf::SsrfAgent::new(h4).run(&t4, &c4).await },
|
||||||
.await
|
);
|
||||||
});
|
|
||||||
|
|
||||||
let t5 = target.clone();
|
let t5 = target.clone();
|
||||||
let c5 = context.clone();
|
let c5 = context.clone();
|
||||||
@@ -182,8 +178,13 @@ impl DastOrchestrator {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
|
|
||||||
let handles: Vec<tokio::task::JoinHandle<Result<Vec<DastFinding>, CoreError>>> =
|
let handles: Vec<tokio::task::JoinHandle<Result<Vec<DastFinding>, CoreError>>> = vec![
|
||||||
vec![sqli_handle, xss_handle, auth_handle, ssrf_handle, api_handle];
|
sqli_handle,
|
||||||
|
xss_handle,
|
||||||
|
auth_handle,
|
||||||
|
ssrf_handle,
|
||||||
|
api_handle,
|
||||||
|
];
|
||||||
|
|
||||||
let mut all_findings = Vec::new();
|
let mut all_findings = Vec::new();
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ impl ReconAgent {
|
|||||||
];
|
];
|
||||||
for header in &missing_security {
|
for header in &missing_security {
|
||||||
if !headers.contains_key(*header) {
|
if !headers.contains_key(*header) {
|
||||||
result.interesting_headers.insert(
|
result
|
||||||
format!("missing:{header}"),
|
.interesting_headers
|
||||||
"Not present".to_string(),
|
.insert(format!("missing:{header}"), "Not present".to_string());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +121,10 @@ impl ReconAgent {
|
|||||||
|
|
||||||
let body_lower = body.to_lowercase();
|
let body_lower = body.to_lowercase();
|
||||||
for (tech, pattern) in &patterns {
|
for (tech, pattern) in &patterns {
|
||||||
if body_lower.contains(&pattern.to_lowercase()) {
|
if body_lower.contains(&pattern.to_lowercase())
|
||||||
if !result.technologies.contains(&tech.to_string()) {
|
&& !result.technologies.contains(&tech.to_string())
|
||||||
result.technologies.push(tech.to_string());
|
{
|
||||||
}
|
result.technologies.push(tech.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
compliance-graph/src/graph/chunking.rs
Normal file
96
compliance-graph/src/graph/chunking.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use compliance_core::models::graph::CodeNode;
|
||||||
|
|
||||||
|
/// A chunk of code extracted from a source file, ready for embedding
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CodeChunk {
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub content: String,
|
||||||
|
pub context_header: String,
|
||||||
|
pub token_estimate: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract embeddable code chunks from parsed CodeNodes.
|
||||||
|
///
|
||||||
|
/// For each node, reads the corresponding source lines from disk,
|
||||||
|
/// builds a context header, and estimates tokens.
|
||||||
|
pub fn extract_chunks(
|
||||||
|
repo_path: &Path,
|
||||||
|
nodes: &[CodeNode],
|
||||||
|
max_chunk_tokens: u32,
|
||||||
|
) -> Vec<CodeChunk> {
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
let file = repo_path.join(&node.file_path);
|
||||||
|
let source = match std::fs::read_to_string(&file) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines: Vec<&str> = source.lines().collect();
|
||||||
|
let start = node.start_line.saturating_sub(1) as usize;
|
||||||
|
let end = (node.end_line as usize).min(lines.len());
|
||||||
|
if start >= end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: String = lines[start..end].join("\n");
|
||||||
|
|
||||||
|
// Skip tiny chunks
|
||||||
|
if content.len() < 50 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate tokens (~4 chars per token)
|
||||||
|
let mut token_estimate = (content.len() / 4) as u32;
|
||||||
|
|
||||||
|
// Truncate if too large
|
||||||
|
let final_content = if token_estimate > max_chunk_tokens {
|
||||||
|
let max_chars = (max_chunk_tokens as usize) * 4;
|
||||||
|
token_estimate = max_chunk_tokens;
|
||||||
|
content.chars().take(max_chars).collect()
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build context header: file path + containing scope hint
|
||||||
|
let context_header = build_context_header(
|
||||||
|
&node.file_path,
|
||||||
|
&node.qualified_name,
|
||||||
|
&node.kind.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
chunks.push(CodeChunk {
|
||||||
|
qualified_name: node.qualified_name.clone(),
|
||||||
|
kind: node.kind.to_string(),
|
||||||
|
file_path: node.file_path.clone(),
|
||||||
|
start_line: node.start_line,
|
||||||
|
end_line: node.end_line,
|
||||||
|
language: node.language.clone(),
|
||||||
|
content: final_content,
|
||||||
|
context_header,
|
||||||
|
token_estimate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_context_header(file_path: &str, qualified_name: &str, kind: &str) -> String {
|
||||||
|
// Extract containing module/class from qualified name
|
||||||
|
// e.g. "src/main.rs::MyStruct::my_method" → parent is "MyStruct"
|
||||||
|
let parts: Vec<&str> = qualified_name.split("::").collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
let parent = parts[..parts.len() - 1].join("::");
|
||||||
|
format!("// {file_path} | {kind} in {parent}")
|
||||||
|
} else {
|
||||||
|
format!("// {file_path} | {kind}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,8 +109,8 @@ pub fn detect_communities(code_graph: &CodeGraph) -> u32 {
|
|||||||
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
|
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
|
||||||
let mut next_id: u32 = 0;
|
let mut next_id: u32 = 0;
|
||||||
for &c in community.values() {
|
for &c in community.values() {
|
||||||
if !comm_remap.contains_key(&c) {
|
if let std::collections::hash_map::Entry::Vacant(e) = comm_remap.entry(c) {
|
||||||
comm_remap.insert(c, next_id);
|
e.insert(next_id);
|
||||||
next_id += 1;
|
next_id += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,7 @@ pub fn detect_communities(code_graph: &CodeGraph) -> u32 {
|
|||||||
|
|
||||||
/// Apply community assignments back to code nodes
|
/// Apply community assignments back to code nodes
|
||||||
pub fn apply_communities(code_graph: &mut CodeGraph) -> u32 {
|
pub fn apply_communities(code_graph: &mut CodeGraph) -> u32 {
|
||||||
let count = detect_communities_with_assignment(code_graph);
|
detect_communities_with_assignment(code_graph)
|
||||||
count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect communities and write assignments into the nodes
|
/// Detect communities and write assignments into the nodes
|
||||||
@@ -235,8 +234,8 @@ fn detect_communities_with_assignment(code_graph: &mut CodeGraph) -> u32 {
|
|||||||
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
|
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
|
||||||
let mut next_id: u32 = 0;
|
let mut next_id: u32 = 0;
|
||||||
for &c in community.values() {
|
for &c in community.values() {
|
||||||
if !comm_remap.contains_key(&c) {
|
if let std::collections::hash_map::Entry::Vacant(e) = comm_remap.entry(c) {
|
||||||
comm_remap.insert(c, next_id);
|
e.insert(next_id);
|
||||||
next_id += 1;
|
next_id += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
236
compliance-graph/src/graph/embedding_store.rs
Normal file
236
compliance-graph/src/graph/embedding_store.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use compliance_core::error::CoreError;
|
||||||
|
use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus};
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
use mongodb::{Collection, Database, IndexModel};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
/// MongoDB persistence layer for code embeddings and vector search
|
||||||
|
pub struct EmbeddingStore {
|
||||||
|
embeddings: Collection<CodeEmbedding>,
|
||||||
|
builds: Collection<EmbeddingBuildRun>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingStore {
|
||||||
|
pub fn new(db: &Database) -> Self {
|
||||||
|
Self {
|
||||||
|
embeddings: db.collection("code_embeddings"),
|
||||||
|
builds: db.collection("embedding_builds"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create standard indexes. NOTE: The Atlas Vector Search index must be
|
||||||
|
/// created via the Atlas UI or CLI with the following definition:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "fields": [
|
||||||
|
/// { "type": "vector", "path": "embedding", "numDimensions": 1536, "similarity": "cosine" },
|
||||||
|
/// { "type": "filter", "path": "repo_id" }
|
||||||
|
/// ]
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub async fn ensure_indexes(&self) -> Result<(), CoreError> {
|
||||||
|
self.embeddings
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "graph_build_id": 1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.builds
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "started_at": -1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all embeddings for a repository
|
||||||
|
pub async fn delete_repo_embeddings(&self, repo_id: &str) -> Result<u64, CoreError> {
|
||||||
|
let result = self
|
||||||
|
.embeddings
|
||||||
|
.delete_many(doc! { "repo_id": repo_id })
|
||||||
|
.await?;
|
||||||
|
info!(
|
||||||
|
"Deleted {} embeddings for repo {repo_id}",
|
||||||
|
result.deleted_count
|
||||||
|
);
|
||||||
|
Ok(result.deleted_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store embeddings in batches of 500
|
||||||
|
pub async fn store_embeddings(&self, embeddings: &[CodeEmbedding]) -> Result<u64, CoreError> {
|
||||||
|
let mut total_inserted = 0u64;
|
||||||
|
for batch in embeddings.chunks(500) {
|
||||||
|
let result = self.embeddings.insert_many(batch).await?;
|
||||||
|
total_inserted += result.inserted_ids.len() as u64;
|
||||||
|
}
|
||||||
|
info!("Stored {total_inserted} embeddings");
|
||||||
|
Ok(total_inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a new build run
|
||||||
|
pub async fn store_build(&self, build: &EmbeddingBuildRun) -> Result<(), CoreError> {
|
||||||
|
self.builds.insert_one(build).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing build run
|
||||||
|
pub async fn update_build(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
graph_build_id: &str,
|
||||||
|
status: EmbeddingBuildStatus,
|
||||||
|
embedded_chunks: u32,
|
||||||
|
error_message: Option<String>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let mut update = doc! {
|
||||||
|
"$set": {
|
||||||
|
"status": mongodb::bson::to_bson(&status).unwrap_or_default(),
|
||||||
|
"embedded_chunks": embedded_chunks as i64,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if status == EmbeddingBuildStatus::Completed || status == EmbeddingBuildStatus::Failed {
|
||||||
|
if let Ok(set_doc) = update.get_document_mut("$set") {
|
||||||
|
set_doc.insert("completed_at", mongodb::bson::DateTime::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(msg) = error_message {
|
||||||
|
if let Ok(set_doc) = update.get_document_mut("$set") {
|
||||||
|
set_doc.insert("error_message", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.builds
|
||||||
|
.update_one(
|
||||||
|
doc! { "repo_id": repo_id, "graph_build_id": graph_build_id },
|
||||||
|
update,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the latest embedding build for a repository
|
||||||
|
pub async fn get_latest_build(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
) -> Result<Option<EmbeddingBuildRun>, CoreError> {
|
||||||
|
Ok(self
|
||||||
|
.builds
|
||||||
|
.find_one(doc! { "repo_id": repo_id })
|
||||||
|
.sort(doc! { "started_at": -1 })
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform vector search. Tries Atlas $vectorSearch first, falls back to
|
||||||
|
/// brute-force cosine similarity for local MongoDB instances.
|
||||||
|
pub async fn vector_search(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
query_embedding: Vec<f64>,
|
||||||
|
limit: u32,
|
||||||
|
min_score: f64,
|
||||||
|
) -> Result<Vec<(CodeEmbedding, f64)>, CoreError> {
|
||||||
|
match self
|
||||||
|
.atlas_vector_search(repo_id, &query_embedding, limit, min_score)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(results) => Ok(results),
|
||||||
|
Err(e) => {
|
||||||
|
info!(
|
||||||
|
"Atlas $vectorSearch unavailable ({e}), falling back to brute-force cosine similarity"
|
||||||
|
);
|
||||||
|
self.bruteforce_vector_search(repo_id, &query_embedding, limit, min_score)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atlas $vectorSearch aggregation stage (requires Atlas Vector Search index)
|
||||||
|
async fn atlas_vector_search(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
query_embedding: &[f64],
|
||||||
|
limit: u32,
|
||||||
|
min_score: f64,
|
||||||
|
) -> Result<Vec<(CodeEmbedding, f64)>, CoreError> {
|
||||||
|
use mongodb::bson::{Bson, Document};
|
||||||
|
|
||||||
|
let pipeline = vec![
|
||||||
|
doc! {
|
||||||
|
"$vectorSearch": {
|
||||||
|
"index": "embedding_vector_index",
|
||||||
|
"path": "embedding",
|
||||||
|
"queryVector": query_embedding.iter().map(|&v| Bson::Double(v)).collect::<Vec<_>>(),
|
||||||
|
"numCandidates": (limit * 10) as i64,
|
||||||
|
"limit": limit as i64,
|
||||||
|
"filter": { "repo_id": repo_id },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
doc! {
|
||||||
|
"$addFields": {
|
||||||
|
"search_score": { "$meta": "vectorSearchScore" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
doc! {
|
||||||
|
"$match": {
|
||||||
|
"search_score": { "$gte": min_score }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut cursor = self.embeddings.aggregate(pipeline).await?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Some(doc) = cursor.try_next().await? {
|
||||||
|
let score = doc.get_f64("search_score").unwrap_or(0.0);
|
||||||
|
let mut clean_doc: Document = doc;
|
||||||
|
clean_doc.remove("search_score");
|
||||||
|
if let Ok(embedding) = mongodb::bson::from_document::<CodeEmbedding>(clean_doc) {
|
||||||
|
results.push((embedding, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brute-force cosine similarity fallback for local MongoDB without Atlas
|
||||||
|
async fn bruteforce_vector_search(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
query_embedding: &[f64],
|
||||||
|
limit: u32,
|
||||||
|
min_score: f64,
|
||||||
|
) -> Result<Vec<(CodeEmbedding, f64)>, CoreError> {
|
||||||
|
let mut cursor = self.embeddings.find(doc! { "repo_id": repo_id }).await?;
|
||||||
|
|
||||||
|
let query_norm = dot(query_embedding, query_embedding).sqrt();
|
||||||
|
let mut scored: Vec<(CodeEmbedding, f64)> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(emb) = cursor.try_next().await? {
|
||||||
|
let doc_norm = dot(&emb.embedding, &emb.embedding).sqrt();
|
||||||
|
let score = if query_norm > 0.0 && doc_norm > 0.0 {
|
||||||
|
dot(query_embedding, &emb.embedding) / (query_norm * doc_norm)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
if score >= min_score {
|
||||||
|
scored.push((emb, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
scored.truncate(limit as usize);
|
||||||
|
Ok(scored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot(a: &[f64], b: &[f64]) -> f64 {
|
||||||
|
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||||
|
}
|
||||||
@@ -133,10 +133,10 @@ impl GraphEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Try to resolve an edge target to a known node
|
/// Try to resolve an edge target to a known node
|
||||||
fn resolve_edge_target<'a>(
|
fn resolve_edge_target(
|
||||||
&self,
|
&self,
|
||||||
target: &str,
|
target: &str,
|
||||||
node_map: &'a HashMap<String, NodeIndex>,
|
node_map: &HashMap<String, NodeIndex>,
|
||||||
) -> Option<NodeIndex> {
|
) -> Option<NodeIndex> {
|
||||||
// Direct match
|
// Direct match
|
||||||
if let Some(idx) = node_map.get(target) {
|
if let Some(idx) = node_map.get(target) {
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ impl<'a> ImpactAnalyzer<'a> {
|
|||||||
file_path: &str,
|
file_path: &str,
|
||||||
line_number: Option<u32>,
|
line_number: Option<u32>,
|
||||||
) -> ImpactAnalysis {
|
) -> ImpactAnalysis {
|
||||||
let mut analysis =
|
let mut analysis = ImpactAnalysis::new(
|
||||||
ImpactAnalysis::new(repo_id.to_string(), finding_id.to_string(), graph_build_id.to_string());
|
repo_id.to_string(),
|
||||||
|
finding_id.to_string(),
|
||||||
|
graph_build_id.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
// Find the node containing the finding
|
// Find the node containing the finding
|
||||||
let target_node = self.find_node_at_location(file_path, line_number);
|
let target_node = self.find_node_at_location(file_path, line_number);
|
||||||
@@ -97,7 +100,11 @@ impl<'a> ImpactAnalyzer<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the graph node at a given file/line location
|
/// Find the graph node at a given file/line location
|
||||||
fn find_node_at_location(&self, file_path: &str, line_number: Option<u32>) -> Option<NodeIndex> {
|
fn find_node_at_location(
|
||||||
|
&self,
|
||||||
|
file_path: &str,
|
||||||
|
line_number: Option<u32>,
|
||||||
|
) -> Option<NodeIndex> {
|
||||||
let mut best: Option<(NodeIndex, u32)> = None; // (index, line_span)
|
let mut best: Option<(NodeIndex, u32)> = None; // (index, line_span)
|
||||||
|
|
||||||
for node in &self.code_graph.nodes {
|
for node in &self.code_graph.nodes {
|
||||||
@@ -166,12 +173,7 @@ impl<'a> ImpactAnalyzer<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find a path from source to target (BFS, limited depth)
|
/// Find a path from source to target (BFS, limited depth)
|
||||||
fn find_path(
|
fn find_path(&self, from: NodeIndex, to: NodeIndex, max_depth: usize) -> Option<Vec<String>> {
|
||||||
&self,
|
|
||||||
from: NodeIndex,
|
|
||||||
to: NodeIndex,
|
|
||||||
max_depth: usize,
|
|
||||||
) -> Option<Vec<String>> {
|
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
let mut queue: VecDeque<(NodeIndex, Vec<NodeIndex>)> = VecDeque::new();
|
let mut queue: VecDeque<(NodeIndex, Vec<NodeIndex>)> = VecDeque::new();
|
||||||
queue.push_back((from, vec![from]));
|
queue.push_back((from, vec![from]));
|
||||||
@@ -209,7 +211,10 @@ impl<'a> ImpactAnalyzer<'a> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_node_by_index(&self, idx: NodeIndex) -> Option<&compliance_core::models::graph::CodeNode> {
|
fn get_node_by_index(
|
||||||
|
&self,
|
||||||
|
idx: NodeIndex,
|
||||||
|
) -> Option<&compliance_core::models::graph::CodeNode> {
|
||||||
let target_gi = idx.index() as u32;
|
let target_gi = idx.index() as u32;
|
||||||
self.code_graph
|
self.code_graph
|
||||||
.nodes
|
.nodes
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
pub mod chunking;
|
||||||
pub mod community;
|
pub mod community;
|
||||||
|
pub mod embedding_store;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod impact;
|
pub mod impact;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
|||||||
@@ -211,8 +211,6 @@ impl GraphStore {
|
|||||||
repo_id: &str,
|
repo_id: &str,
|
||||||
graph_build_id: &str,
|
graph_build_id: &str,
|
||||||
) -> Result<Vec<CommunityInfo>, CoreError> {
|
) -> Result<Vec<CommunityInfo>, CoreError> {
|
||||||
|
|
||||||
|
|
||||||
let filter = doc! {
|
let filter = doc! {
|
||||||
"repo_id": repo_id,
|
"repo_id": repo_id,
|
||||||
"graph_build_id": graph_build_id,
|
"graph_build_id": graph_build_id,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
#![allow(clippy::only_used_in_recursion)]
|
||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod parsers;
|
pub mod parsers;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser};
|
|||||||
|
|
||||||
pub struct JavaScriptParser;
|
pub struct JavaScriptParser;
|
||||||
|
|
||||||
|
impl Default for JavaScriptParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl JavaScriptParser {
|
impl JavaScriptParser {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
@@ -51,7 +57,13 @@ impl JavaScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified, output,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +109,12 @@ impl JavaScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.walk_children(
|
self.walk_children(
|
||||||
body, source, file_path, repo_id, graph_build_id, Some(&qualified),
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
Some(&qualified),
|
||||||
output,
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,7 +147,13 @@ impl JavaScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified, output,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +161,13 @@ impl JavaScriptParser {
|
|||||||
// Arrow functions assigned to variables: const foo = () => {}
|
// Arrow functions assigned to variables: const foo = () => {}
|
||||||
"lexical_declaration" | "variable_declaration" => {
|
"lexical_declaration" | "variable_declaration" => {
|
||||||
self.extract_arrow_functions(
|
self.extract_arrow_functions(
|
||||||
node, source, file_path, repo_id, graph_build_id, parent_qualified, output,
|
node,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
parent_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"import_statement" => {
|
"import_statement" => {
|
||||||
@@ -183,7 +212,13 @@ impl JavaScriptParser {
|
|||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
self.walk_tree(
|
self.walk_tree(
|
||||||
child, source, file_path, repo_id, graph_build_id, parent_qualified, output,
|
child,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
parent_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +252,13 @@ impl JavaScriptParser {
|
|||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
child, source, file_path, repo_id, graph_build_id, caller_qualified, output,
|
child,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
caller_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +304,12 @@ impl JavaScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = value_n.child_by_field_name("body") {
|
if let Some(body) = value_n.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
output,
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser};
|
|||||||
|
|
||||||
pub struct PythonParser;
|
pub struct PythonParser;
|
||||||
|
|
||||||
|
impl Default for PythonParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PythonParser {
|
impl PythonParser {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ impl ParserRegistry {
|
|||||||
repo_id: &str,
|
repo_id: &str,
|
||||||
graph_build_id: &str,
|
graph_build_id: &str,
|
||||||
) -> Result<Option<ParseOutput>, CoreError> {
|
) -> Result<Option<ParseOutput>, CoreError> {
|
||||||
let ext = file_path
|
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let parser_idx = match self.extension_map.get(ext) {
|
let parser_idx = match self.extension_map.get(ext) {
|
||||||
Some(idx) => *idx,
|
Some(idx) => *idx,
|
||||||
@@ -89,7 +86,15 @@ impl ParserRegistry {
|
|||||||
let mut combined = ParseOutput::default();
|
let mut combined = ParseOutput::default();
|
||||||
let mut node_count: u32 = 0;
|
let mut node_count: u32 = 0;
|
||||||
|
|
||||||
self.walk_directory(dir, dir, repo_id, graph_build_id, max_nodes, &mut node_count, &mut combined)?;
|
self.walk_directory(
|
||||||
|
dir,
|
||||||
|
dir,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
max_nodes,
|
||||||
|
&mut node_count,
|
||||||
|
&mut combined,
|
||||||
|
)?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
nodes = combined.nodes.len(),
|
nodes = combined.nodes.len(),
|
||||||
@@ -162,8 +167,7 @@ impl ParserRegistry {
|
|||||||
Err(_) => continue, // Skip binary/unreadable files
|
Err(_) => continue, // Skip binary/unreadable files
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(output) = self.parse_file(rel_path, &source, repo_id, graph_build_id)?
|
if let Some(output) = self.parse_file(rel_path, &source, repo_id, graph_build_id)? {
|
||||||
{
|
|
||||||
*node_count += output.nodes.len() as u32;
|
*node_count += output.nodes.len() as u32;
|
||||||
combined.nodes.extend(output.nodes);
|
combined.nodes.extend(output.nodes);
|
||||||
combined.edges.extend(output.edges);
|
combined.edges.extend(output.edges);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser};
|
|||||||
|
|
||||||
pub struct RustParser;
|
pub struct RustParser;
|
||||||
|
|
||||||
|
impl Default for RustParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RustParser {
|
impl RustParser {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
@@ -196,9 +202,7 @@ impl RustParser {
|
|||||||
id: None,
|
id: None,
|
||||||
repo_id: repo_id.to_string(),
|
repo_id: repo_id.to_string(),
|
||||||
graph_build_id: graph_build_id.to_string(),
|
graph_build_id: graph_build_id.to_string(),
|
||||||
source: parent_qualified
|
source: parent_qualified.unwrap_or(file_path).to_string(),
|
||||||
.unwrap_or(file_path)
|
|
||||||
.to_string(),
|
|
||||||
target: path,
|
target: path,
|
||||||
kind: CodeEdgeKind::Imports,
|
kind: CodeEdgeKind::Imports,
|
||||||
file_path: file_path.to_string(),
|
file_path: file_path.to_string(),
|
||||||
@@ -354,10 +358,7 @@ impl RustParser {
|
|||||||
|
|
||||||
fn extract_use_path(&self, use_text: &str) -> Option<String> {
|
fn extract_use_path(&self, use_text: &str) -> Option<String> {
|
||||||
// "use foo::bar::baz;" -> "foo::bar::baz"
|
// "use foo::bar::baz;" -> "foo::bar::baz"
|
||||||
let trimmed = use_text
|
let trimmed = use_text.strip_prefix("use ")?.trim_end_matches(';').trim();
|
||||||
.strip_prefix("use ")?
|
|
||||||
.trim_end_matches(';')
|
|
||||||
.trim();
|
|
||||||
Some(trimmed.to_string())
|
Some(trimmed.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ use tree_sitter::{Node, Parser};
|
|||||||
|
|
||||||
pub struct TypeScriptParser;
|
pub struct TypeScriptParser;
|
||||||
|
|
||||||
|
impl Default for TypeScriptParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TypeScriptParser {
|
impl TypeScriptParser {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
@@ -49,7 +55,13 @@ impl TypeScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified, output,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,12 +92,23 @@ impl TypeScriptParser {
|
|||||||
|
|
||||||
// Heritage clause (extends/implements)
|
// Heritage clause (extends/implements)
|
||||||
self.extract_heritage(
|
self.extract_heritage(
|
||||||
&node, source, file_path, repo_id, graph_build_id, &qualified, output,
|
&node,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.walk_children(
|
self.walk_children(
|
||||||
body, source, file_path, repo_id, graph_build_id, Some(&qualified),
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
Some(&qualified),
|
||||||
output,
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,14 +166,26 @@ impl TypeScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = node.child_by_field_name("body") {
|
if let Some(body) = node.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified, output,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"lexical_declaration" | "variable_declaration" => {
|
"lexical_declaration" | "variable_declaration" => {
|
||||||
self.extract_arrow_functions(
|
self.extract_arrow_functions(
|
||||||
node, source, file_path, repo_id, graph_build_id, parent_qualified, output,
|
node,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
parent_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"import_statement" => {
|
"import_statement" => {
|
||||||
@@ -172,7 +207,13 @@ impl TypeScriptParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.walk_children(
|
self.walk_children(
|
||||||
node, source, file_path, repo_id, graph_build_id, parent_qualified, output,
|
node,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
parent_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +230,13 @@ impl TypeScriptParser {
|
|||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
self.walk_tree(
|
self.walk_tree(
|
||||||
child, source, file_path, repo_id, graph_build_id, parent_qualified, output,
|
child,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
parent_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +270,13 @@ impl TypeScriptParser {
|
|||||||
let mut cursor = node.walk();
|
let mut cursor = node.walk();
|
||||||
for child in node.children(&mut cursor) {
|
for child in node.children(&mut cursor) {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
child, source, file_path, repo_id, graph_build_id, caller_qualified, output,
|
child,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
caller_qualified,
|
||||||
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +322,12 @@ impl TypeScriptParser {
|
|||||||
|
|
||||||
if let Some(body) = value_n.child_by_field_name("body") {
|
if let Some(body) = value_n.child_by_field_name("body") {
|
||||||
self.extract_calls(
|
self.extract_calls(
|
||||||
body, source, file_path, repo_id, graph_build_id, &qualified,
|
body,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
&qualified,
|
||||||
output,
|
output,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,10 @@ impl SymbolIndex {
|
|||||||
.map_err(|e| CoreError::Graph(format!("Failed to create reader: {e}")))?;
|
.map_err(|e| CoreError::Graph(format!("Failed to create reader: {e}")))?;
|
||||||
|
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
let query_parser =
|
let query_parser = QueryParser::for_index(
|
||||||
QueryParser::for_index(&self.index, vec![self.name_field, self.qualified_name_field]);
|
&self.index,
|
||||||
|
vec![self.name_field, self.qualified_name_field],
|
||||||
|
);
|
||||||
|
|
||||||
let query = query_parser
|
let query = query_parser
|
||||||
.parse_query(query_str)
|
.parse_query(query_str)
|
||||||
|
|||||||
Reference in New Issue
Block a user