feat: add new scanners, enhanced triage, findings refinement, and deployment tooling
Some checks failed
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Format (push) Failing after 3s
CI / Clippy (push) Failing after 2m44s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 3s
CI / Clippy (pull_request) Failing after 2m51s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Some checks failed
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Format (push) Failing after 3s
CI / Clippy (push) Failing after 2m44s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 3s
CI / Clippy (pull_request) Failing after 2m51s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
- Add gitleaks secret detection, lint scanning (clippy/eslint/ruff), and LLM code review scanners - Enhance LLM triage with multi-action support (confirm/downgrade/upgrade/dismiss), surrounding code context, and file-path classification confidence adjustment - Add text search, column sorting, and bulk status update to findings dashboard - Fix finding detail page status refresh and add developer feedback field - Fix BSON DateTime deserialization across all models with shared serde helpers - Add scan progress spinner with polling to repositories page - Batch OSV.dev queries to avoid "Too many queries" errors - Add gitleaks, semgrep, and ruff to Dockerfile.agent for deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,16 @@ pub struct FindingsListResponse {
|
||||
}
|
||||
|
||||
#[server]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn fetch_findings(
|
||||
page: u64,
|
||||
severity: String,
|
||||
scan_type: String,
|
||||
status: String,
|
||||
repo_id: String,
|
||||
q: String,
|
||||
sort_by: String,
|
||||
sort_order: String,
|
||||
) -> Result<FindingsListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
@@ -37,6 +41,18 @@ pub async fn fetch_findings(
|
||||
if !repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={repo_id}"));
|
||||
}
|
||||
if !q.is_empty() {
|
||||
url.push_str(&format!(
|
||||
"&q={}",
|
||||
url::form_urlencoded::byte_serialize(q.as_bytes()).collect::<String>()
|
||||
));
|
||||
}
|
||||
if !sort_by.is_empty() {
|
||||
url.push_str(&format!("&sort_by={sort_by}"));
|
||||
}
|
||||
if !sort_order.is_empty() {
|
||||
url.push_str(&format!("&sort_order={sort_order}"));
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
@@ -82,3 +98,43 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn bulk_update_finding_status(
|
||||
ids: Vec<String>,
|
||||
status: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "ids": ids, "status": status }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn update_finding_feedback(
|
||||
id: String,
|
||||
feedback: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.patch(&url)
|
||||
.json(&serde_json::json!({ "feedback": feedback }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -99,3 +99,35 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a repository has any running scans
|
||||
#[server]
|
||||
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/scan-runs?page=1&limit=1",
|
||||
state.agent_api_url
|
||||
);
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
// Check if the most recent scan for this repo is still running
|
||||
if let Some(scans) = body.get("data").and_then(|d| d.as_array()) {
|
||||
for scan in scans {
|
||||
let scan_repo = scan.get("repo_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = scan.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if scan_repo == repo_id && status == "running" {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::components::severity_badge::SeverityBadge;
|
||||
pub fn FindingDetailPage(id: String) -> Element {
|
||||
let finding_id = id.clone();
|
||||
|
||||
let finding = use_resource(move || {
|
||||
let mut finding = use_resource(move || {
|
||||
let fid = finding_id.clone();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_finding_detail(fid)
|
||||
@@ -22,6 +22,8 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
match snapshot {
|
||||
Some(Some(f)) => {
|
||||
let finding_id_for_status = id.clone();
|
||||
let finding_id_for_feedback = id.clone();
|
||||
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: f.title.clone(),
|
||||
@@ -39,6 +41,9 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
if let Some(score) = f.cvss_score {
|
||||
span { class: "badge badge-medium", "CVSS: {score}" }
|
||||
}
|
||||
if let Some(confidence) = f.confidence {
|
||||
span { class: "badge badge-info", "Confidence: {confidence:.1}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
@@ -46,6 +51,19 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
p { "{f.description}" }
|
||||
}
|
||||
|
||||
if let Some(rationale) = &f.triage_rationale {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Triage Rationale" }
|
||||
div {
|
||||
style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;",
|
||||
if let Some(action) = &f.triage_action {
|
||||
span { class: "badge badge-info", "{action}" }
|
||||
}
|
||||
}
|
||||
p { style: "color: var(--text-secondary); font-size: 14px;", "{rationale}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = &f.code_snippet {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Code Evidence" }
|
||||
@@ -99,6 +117,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
||||
});
|
||||
finding.restart();
|
||||
},
|
||||
"{status}"
|
||||
}
|
||||
@@ -107,6 +126,25 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Developer Feedback" }
|
||||
p {
|
||||
style: "font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||
"Share your assessment of this finding (e.g. false positive, actionable, needs context)"
|
||||
}
|
||||
textarea {
|
||||
style: "width: 100%; min-height: 80px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 14px; resize: vertical;",
|
||||
value: "{existing_feedback}",
|
||||
oninput: move |e| {
|
||||
let feedback = e.value();
|
||||
let id = finding_id_for_feedback.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_feedback(id, feedback).await;
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => rsx! {
|
||||
|
||||
@@ -12,6 +12,10 @@ pub fn FindingsPage() -> Element {
|
||||
let mut type_filter = use_signal(String::new);
|
||||
let mut status_filter = use_signal(String::new);
|
||||
let mut repo_filter = use_signal(String::new);
|
||||
let mut search_query = use_signal(String::new);
|
||||
let mut sort_by = use_signal(|| "created_at".to_string());
|
||||
let mut sort_order = use_signal(|| "desc".to_string());
|
||||
let mut selected_ids = use_signal(Vec::<String>::new);
|
||||
|
||||
let repos = use_resource(|| async {
|
||||
crate::infrastructure::repositories::fetch_repositories(1)
|
||||
@@ -25,13 +29,44 @@ pub fn FindingsPage() -> Element {
|
||||
let typ = type_filter();
|
||||
let stat = status_filter();
|
||||
let repo = repo_filter();
|
||||
let q = search_query();
|
||||
let sb = sort_by();
|
||||
let so = sort_order();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo)
|
||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo, q, sb, so)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_sort = move |field: &'static str| {
|
||||
move |_: MouseEvent| {
|
||||
if sort_by() == field {
|
||||
sort_order.set(if sort_order() == "asc" {
|
||||
"desc".to_string()
|
||||
} else {
|
||||
"asc".to_string()
|
||||
});
|
||||
} else {
|
||||
sort_by.set(field.to_string());
|
||||
sort_order.set("desc".to_string());
|
||||
}
|
||||
page.set(1);
|
||||
}
|
||||
};
|
||||
|
||||
let sort_indicator = move |field: &str| -> String {
|
||||
if sort_by() == field {
|
||||
if sort_order() == "asc" {
|
||||
" \u{25B2}".to_string()
|
||||
} else {
|
||||
" \u{25BC}".to_string()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Findings",
|
||||
@@ -39,6 +74,12 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
|
||||
div { class: "filter-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search findings...",
|
||||
style: "min-width: 200px;",
|
||||
oninput: move |e| { search_query.set(e.value()); page.set(1); },
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Repositories" }
|
||||
@@ -76,6 +117,9 @@ pub fn FindingsPage() -> Element {
|
||||
option { value: "cve", "CVE" }
|
||||
option { value: "gdpr", "GDPR" }
|
||||
option { value: "oauth", "OAuth" }
|
||||
option { value: "secret_detection", "Secrets" }
|
||||
option { value: "lint", "Lint" }
|
||||
option { value: "code_review", "Code Review" }
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
||||
@@ -88,29 +132,123 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk action bar
|
||||
if !selected_ids().is_empty() {
|
||||
div {
|
||||
class: "card",
|
||||
style: "display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 16px; background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.2);",
|
||||
span {
|
||||
style: "font-size: 14px; color: var(--text-secondary);",
|
||||
"{selected_ids().len()} selected"
|
||||
}
|
||||
for status in ["triaged", "resolved", "false_positive", "ignored"] {
|
||||
{
|
||||
let status_str = status.to_string();
|
||||
let label = match status {
|
||||
"false_positive" => "False Positive",
|
||||
other => {
|
||||
// Capitalize first letter
|
||||
let mut s = other.to_string();
|
||||
if let Some(c) = s.get_mut(0..1) { c.make_ascii_uppercase(); }
|
||||
// Leak to get a &str that lives long enough - this is fine for static-ish UI strings
|
||||
&*Box::leak(s.into_boxed_str())
|
||||
}
|
||||
};
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| {
|
||||
let ids = selected_ids();
|
||||
let s = status_str.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await;
|
||||
});
|
||||
selected_ids.set(Vec::new());
|
||||
},
|
||||
"Mark {label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| { selected_ids.set(Vec::new()); },
|
||||
"Clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match &*findings.read() {
|
||||
Some(Some(resp)) => {
|
||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||
let all_ids: Vec<String> = resp.data.iter().filter_map(|f| f.id.as_ref().map(|id| id.to_hex())).collect();
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Severity" }
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th {
|
||||
style: "width: 40px;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: !all_ids.is_empty() && selected_ids().len() == all_ids.len(),
|
||||
onchange: move |_| {
|
||||
if selected_ids().len() == all_ids.len() {
|
||||
selected_ids.set(Vec::new());
|
||||
} else {
|
||||
selected_ids.set(all_ids.clone());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("severity"),
|
||||
"Severity{sort_indicator(\"severity\")}"
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("title"),
|
||||
"Title{sort_indicator(\"title\")}"
|
||||
}
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("scan_type"),
|
||||
"Type{sort_indicator(\"scan_type\")}"
|
||||
}
|
||||
th { "Scanner" }
|
||||
th { "File" }
|
||||
th { "Status" }
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("status"),
|
||||
"Status{sort_indicator(\"status\")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in &resp.data {
|
||||
{
|
||||
let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||
let id_for_check = id.clone();
|
||||
let is_selected = selected_ids().contains(&id);
|
||||
rsx! {
|
||||
tr {
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_selected,
|
||||
onchange: move |_| {
|
||||
let mut ids = selected_ids();
|
||||
if ids.contains(&id_for_check) {
|
||||
ids.retain(|i| i != &id_for_check);
|
||||
} else {
|
||||
ids.push(id_for_check.clone());
|
||||
}
|
||||
selected_ids.set(ids);
|
||||
},
|
||||
}
|
||||
}
|
||||
td { SeverityBadge { severity: finding.severity.to_string() } }
|
||||
td {
|
||||
Link {
|
||||
|
||||
@@ -5,6 +5,17 @@ use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
|
||||
async fn async_sleep_5s() {
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
gloo_timers::future::TimeoutFuture::new(5_000).await;
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn RepositoriesPage() -> Element {
|
||||
let mut page = use_signal(|| 1u64);
|
||||
@@ -14,6 +25,7 @@ pub fn RepositoriesPage() -> Element {
|
||||
let mut branch = use_signal(|| "main".to_string());
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||
|
||||
let mut repos = use_resource(move || {
|
||||
let p = page();
|
||||
@@ -158,6 +170,7 @@ pub fn RepositoriesPage() -> Element {
|
||||
let repo_id_scan = repo_id.clone();
|
||||
let repo_id_del = repo_id.clone();
|
||||
let repo_name_del = repo.name.clone();
|
||||
let is_scanning = scanning_ids().contains(&repo_id);
|
||||
rsx! {
|
||||
tr {
|
||||
td { "{repo.name}" }
|
||||
@@ -192,17 +205,44 @@ pub fn RepositoriesPage() -> Element {
|
||||
"Graph"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||
disabled: is_scanning,
|
||||
onclick: move |_| {
|
||||
let id = repo_id_scan.clone();
|
||||
// Add to scanning set
|
||||
let mut ids = scanning_ids();
|
||||
ids.push(id.clone());
|
||||
scanning_ids.set(ids);
|
||||
spawn(async move {
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Scan triggered");
|
||||
// Poll until scan completes
|
||||
loop {
|
||||
async_sleep_5s().await;
|
||||
match crate::infrastructure::repositories::check_repo_scanning(id.clone()).await {
|
||||
Ok(false) => break,
|
||||
Ok(true) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
toasts.push(ToastType::Success, "Scan complete");
|
||||
repos.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
// Remove from scanning set
|
||||
let mut ids = scanning_ids();
|
||||
ids.retain(|i| i != &id);
|
||||
scanning_ids.set(ids);
|
||||
});
|
||||
},
|
||||
"Scan"
|
||||
if is_scanning {
|
||||
span { class: "spinner" }
|
||||
"Scanning..."
|
||||
} else {
|
||||
"Scan"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-ghost-danger",
|
||||
|
||||
Reference in New Issue
Block a user