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

- 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:
Sharang Parnerkar
2026-03-09 11:05:31 +01:00
parent 32e5fc21e7
commit 23ba52276b
31 changed files with 1602 additions and 95 deletions

View File

@@ -609,6 +609,24 @@ tbody tr:last-child td {
background: var(--danger-bg);
}
.btn-scanning {
opacity: 0.7;
cursor: not-allowed;
display: inline-flex;
align-items: center;
gap: 6px;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border-bright);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.btn-danger {
background: var(--danger);
color: #fff;

View File

@@ -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(())
}

View File

@@ -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)
}

View File

@@ -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! {

View File

@@ -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 {

View File

@@ -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",