From dc9cdacab95a1ae2996516d47fed7155e9e94729 Mon Sep 17 00:00:00 2001 From: Brandon Date: Sat, 18 Jul 2026 18:19:59 -0400 Subject: [PATCH] feat: add remediation priority summaries --- README.md | 7 +- src/report/html.rs | 103 ++++++++++++++++++++++++++ src/report/json.rs | 9 +++ src/report/markdown.rs | 37 ++++++++++ src/report/mod.rs | 1 + src/report/remediation.rs | 147 +++++++++++++++++++++++++++++++++++++ src/report/terminal.rs | 36 +++++++++ tests/integration_tests.rs | 36 +++++++++ 8 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 src/report/remediation.rs diff --git a/README.md b/README.md index 333a742..3176b5f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Full methodology, source attribution, and raw output are in [`research/FINDINGS. ## Trust signals - **4.0 MB** release binary -- **255/255 tests passing** +- **267/267 tests passing** - **0 clippy warnings** with `-D warnings` - **0 known Rust dependency vulnerabilities** (`cargo audit`) @@ -171,6 +171,9 @@ agentwise scan . --supply-chain # Fail CI on high+ severity findings agentwise scan . --fail-on high + +# Produce a Markdown report with remediation priorities +agentwise scan . --format markdown --output agentwise-report.md ``` ### Supported Configs @@ -185,6 +188,8 @@ agentwise auto-detects and scans: - Any JSON file with `mcpServers` or `context_servers` passed as argument - Any Codex `config.toml` with `[mcp_servers.]` tables passed as argument +Reports also include a remediation priority section. It groups findings into fix-now, schedule-next, and monitor lanes so product and security owners can decide what to clean up first without sorting raw findings by hand. + ## Threat coverage

diff --git a/src/report/html.rs b/src/report/html.rs index 41f0e52..998619c 100644 --- a/src/report/html.rs +++ b/src/report/html.rs @@ -1,3 +1,4 @@ +use crate::report::remediation::{self, RemediationPlan}; use crate::rules::{Finding, Severity}; use crate::scanner::ScanResult; use std::f64::consts::PI; @@ -168,6 +169,56 @@ main.report { .swatch.high { background: var(--high); } .swatch.medium { background: var(--medium); } .swatch.low { background: var(--low); } +.remediation-grid { + display: grid; + grid-template-columns: repeat(3, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} +.priority-lane { + border: 1px solid var(--border); + border-radius: 10px; + background: #10161d; + padding: 12px; +} +.priority-label { + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.priority-value { + margin-top: 4px; + font-size: 1.4rem; + font-weight: 800; +} +.action-list { + display: grid; + gap: 10px; +} +.action-row { + border-top: 1px solid var(--border); + padding-top: 10px; +} +.action-row:first-child { + border-top: 0; + padding-top: 0; +} +.action-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.action-title { + margin: 4px 0; + font-weight: 700; +} +.action-fix { + margin: 0; + color: var(--muted); +} .findings-grid { display: grid; gap: 12px; @@ -305,6 +356,9 @@ main.report { .legend { grid-template-columns: repeat(2, minmax(100px, 1fr)); } + .remediation-grid { + grid-template-columns: 1fr; + } } @media print { :root { color-scheme: light; } @@ -342,6 +396,8 @@ pub fn render(result: &ScanResult) -> String { let score_gauge = render_score_gauge(result.score, &result.grade); let severity_chart = render_severity_chart(critical, high, medium, low); + let remediation_plan = remediation::build(&result.findings); + let remediation_html = render_remediation_priority(&remediation_plan); let timestamp = current_timestamp(); let mut out = String::new(); @@ -401,6 +457,11 @@ pub fn render(result: &ScanResult) -> String { out.push_str(&severity_chart); out.push_str(" \n"); + out.push_str("

\n"); + out.push_str("

Remediation priority

\n"); + out.push_str(&remediation_html); + out.push_str("
\n"); + out.push_str("
\n"); out.push_str("

Findings

\n"); out.push_str("
\n"); @@ -418,6 +479,47 @@ pub fn render(result: &ScanResult) -> String { out } +fn render_remediation_priority(plan: &RemediationPlan) -> String { + let mut out = String::new(); + + out.push_str("
\n"); + append_priority_lane(&mut out, "Fix now", plan.fix_now); + append_priority_lane(&mut out, "Schedule next", plan.schedule_next); + append_priority_lane(&mut out, "Monitor", plan.monitor); + out.push_str("
\n"); + + if plan.top_actions.is_empty() { + out.push_str("

No remediation action needed.

\n"); + return out; + } + + out.push_str("
\n"); + for action in &plan.top_actions { + let _ = writeln!( + out, + "
{}{} affected
{}: {}

{}

", + severity_class(action.severity), + escape_html(remediation::lane_label(action.lane)), + action.affected, + escape_html(&action.rule_id), + escape_html(&action.title), + escape_html(&action.fix) + ); + } + out.push_str("
\n"); + + out +} + +fn append_priority_lane(out: &mut String, label: &str, value: usize) { + let _ = writeln!( + out, + "
{}
{}
", + escape_html(label), + value + ); +} + fn append_stat(out: &mut String, label: &str, value: T) { let _ = writeln!( out, @@ -686,6 +788,7 @@ mod tests { let html = render(&result); assert!(html.contains("")); assert!(html.contains("No findings detected")); + assert!(html.contains("Remediation priority")); assert!(html.contains("agentwise Security Report")); assert!(html.contains(&format!( "Generated by agentwise v{}", diff --git a/src/report/json.rs b/src/report/json.rs index a84a331..d52791a 100644 --- a/src/report/json.rs +++ b/src/report/json.rs @@ -1,3 +1,4 @@ +use crate::report::remediation::{self, RemediationPlan}; use crate::scanner::ScanResult; use serde::Serialize; @@ -12,6 +13,7 @@ struct JsonReport<'a> { suppressed_count: usize, findings: &'a [crate::rules::Finding], summary: Summary, + remediation_plan: RemediationPlan, } #[derive(Serialize)] @@ -49,6 +51,7 @@ pub fn render(result: &ScanResult) -> String { .count(), total: result.findings.len(), }; + let remediation_plan = remediation::build(&result.findings); let report = JsonReport { version: env!("CARGO_PKG_VERSION"), @@ -60,6 +63,7 @@ pub fn render(result: &ScanResult) -> String { suppressed_count: result.suppressed_count, findings: &result.findings, summary, + remediation_plan, }; serde_json::to_string_pretty(&report).unwrap() @@ -98,6 +102,11 @@ mod tests { assert_eq!(parsed["score"], 80); assert_eq!(parsed["suppressed_count"], 0); assert_eq!(parsed["summary"]["critical"], 1); + assert_eq!(parsed["remediation_plan"]["fix_now"], 1); + assert_eq!( + parsed["remediation_plan"]["top_actions"][0]["lane"], + "fix_now" + ); assert_eq!(parsed["findings"].as_array().unwrap().len(), 1); } diff --git a/src/report/markdown.rs b/src/report/markdown.rs index 92e9a33..c0be41c 100644 --- a/src/report/markdown.rs +++ b/src/report/markdown.rs @@ -1,3 +1,4 @@ +use crate::report::remediation; use crate::rules::{Finding, Severity}; use crate::scanner::ScanResult; @@ -73,6 +74,8 @@ pub fn render(result: &ScanResult) -> String { escape_table_cell(&total.to_string()) )); + out.push_str(&render_remediation_priority(result)); + out.push_str("## Findings\n\n"); if result.findings.is_empty() { @@ -101,6 +104,39 @@ pub fn render(result: &ScanResult) -> String { out } +fn render_remediation_priority(result: &ScanResult) -> String { + let plan = remediation::build(&result.findings); + let mut out = String::new(); + + out.push_str("## Remediation priority\n\n"); + out.push_str("| Lane | Findings |\n"); + out.push_str("|------|----------|\n"); + out.push_str(&format!("| Fix now | {} |\n", plan.fix_now)); + out.push_str(&format!("| Schedule next | {} |\n", plan.schedule_next)); + out.push_str(&format!("| Monitor | {} |\n\n", plan.monitor)); + + if plan.top_actions.is_empty() { + out.push_str("No remediation action needed.\n\n"); + return out; + } + + out.push_str("| Priority | Rule | Action | Affected |\n"); + out.push_str("|----------|------|--------|----------|\n"); + + for action in &plan.top_actions { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + escape_table_cell(remediation::lane_label(action.lane)), + escape_table_cell(&action.rule_id), + escape_table_cell(&format!("{}: {}", action.title, action.fix)), + escape_table_cell(&format!("{} affected", action.affected)) + )); + } + + out.push('\n'); + out +} + fn render_finding(finding: &Finding) -> String { let icon = match finding.severity { Severity::Critical => "✖", @@ -187,6 +223,7 @@ mod tests { let output = render(&result); assert!(output.contains("## Findings")); + assert!(output.contains("## Remediation priority")); assert!(output.contains("### ✖ CRITICAL:")); assert!(output.contains("- **Rule:**")); assert!(output.contains("- **Fix:**")); diff --git a/src/report/mod.rs b/src/report/mod.rs index 0341421..d138af3 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -1,6 +1,7 @@ pub mod html; pub mod json; pub mod markdown; +pub mod remediation; pub mod sarif; pub mod terminal; diff --git a/src/report/remediation.rs b/src/report/remediation.rs new file mode 100644 index 0000000..25ffdcb --- /dev/null +++ b/src/report/remediation.rs @@ -0,0 +1,147 @@ +use crate::rules::{Finding, Severity}; +use serde::Serialize; + +const MAX_TOP_ACTIONS: usize = 5; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RemediationPlan { + pub fix_now: usize, + pub schedule_next: usize, + pub monitor: usize, + pub top_actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RemediationAction { + pub severity: Severity, + pub lane: &'static str, + pub rule_id: String, + pub title: String, + pub fix: String, + pub affected: usize, + pub affected_servers: Vec, +} + +pub fn build(findings: &[Finding]) -> RemediationPlan { + let mut fix_now = 0; + let mut schedule_next = 0; + let mut monitor = 0; + let mut actions: Vec = Vec::new(); + + for finding in findings { + match lane_for_severity(finding.severity) { + "fix_now" => fix_now += 1, + "schedule_next" => schedule_next += 1, + _ => monitor += 1, + } + + let affected = format!("{} -> {}", finding.config_file, finding.server_name); + + if let Some(action) = actions.iter_mut().find(|action| { + action.severity == finding.severity + && action.rule_id == finding.rule_id + && action.title == finding.title + && action.fix == finding.fix + }) { + if !action.affected_servers.contains(&affected) { + action.affected_servers.push(affected); + } + continue; + } + + actions.push(RemediationAction { + severity: finding.severity, + lane: lane_for_severity(finding.severity), + rule_id: finding.rule_id.clone(), + title: finding.title.clone(), + fix: finding.fix.clone(), + affected: 1, + affected_servers: vec![affected], + }); + } + + for action in &mut actions { + action.affected_servers.sort(); + action.affected = action.affected_servers.len(); + } + + actions.sort_by(|a, b| { + b.severity + .cmp(&a.severity) + .then_with(|| b.affected.cmp(&a.affected)) + .then_with(|| a.rule_id.cmp(&b.rule_id)) + .then_with(|| a.title.cmp(&b.title)) + }); + actions.truncate(MAX_TOP_ACTIONS); + + RemediationPlan { + fix_now, + schedule_next, + monitor, + top_actions: actions, + } +} + +pub fn lane_for_severity(severity: Severity) -> &'static str { + match severity { + Severity::Critical | Severity::High => "fix_now", + Severity::Medium => "schedule_next", + Severity::Low => "monitor", + } +} + +pub fn lane_label(lane: &str) -> &'static str { + match lane { + "fix_now" => "Fix now", + "schedule_next" => "Schedule next", + _ => "Monitor", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finding(severity: Severity, rule_id: &str, title: &str, server_name: &str) -> Finding { + Finding { + rule_id: rule_id.to_string(), + severity, + title: title.to_string(), + message: "message".to_string(), + fix: "Apply the recommended control".to_string(), + config_file: ".mcp.json".to_string(), + server_name: server_name.to_string(), + source: None, + epss: None, + sub_items: None, + } + } + + #[test] + fn build_counts_priority_lanes() { + let plan = build(&[ + finding(Severity::Critical, "AW-001", "No auth", "remote"), + finding(Severity::High, "AW-004", "Secret", "slack"), + finding(Severity::Medium, "AW-007", "No allowlist", "github"), + finding(Severity::Low, "AW-013", "Low signal", "demo"), + ]); + + assert_eq!(plan.fix_now, 2); + assert_eq!(plan.schedule_next, 1); + assert_eq!(plan.monitor, 1); + } + + #[test] + fn build_groups_duplicate_actions_by_rule_title_and_fix() { + let plan = build(&[ + finding(Severity::Critical, "AW-001", "No auth", "remote-a"), + finding(Severity::Critical, "AW-001", "No auth", "remote-b"), + finding(Severity::Medium, "AW-007", "No allowlist", "github"), + ]); + + assert_eq!(plan.top_actions[0].rule_id, "AW-001"); + assert_eq!(plan.top_actions[0].affected, 2); + assert_eq!(plan.top_actions[0].lane, "fix_now"); + assert_eq!(plan.top_actions[1].lane, "schedule_next"); + } +} diff --git a/src/report/terminal.rs b/src/report/terminal.rs index 154c908..645ed9a 100644 --- a/src/report/terminal.rs +++ b/src/report/terminal.rs @@ -1,4 +1,5 @@ use crate::discover::DiscoveredConfig; +use crate::report::remediation; use crate::rules::{Finding, Severity}; use crate::scanner::{OsvStats, ScanResult}; use owo_colors::OwoColorize; @@ -23,6 +24,10 @@ pub fn render(result: &ScanResult) -> String { out.push_str(&render_severity_summary(result)); out.push('\n'); + // Remediation priority summary + out.push_str(&render_remediation_priority(result)); + out.push('\n'); + // Individual findings let mut sorted = result.findings.clone(); sorted.sort_by_key(|finding| std::cmp::Reverse(finding.severity)); @@ -131,6 +136,36 @@ fn render_clean() -> String { format!("{}\n{}\n{}\n", top, mid, bot) } +fn render_remediation_priority(result: &ScanResult) -> String { + let plan = remediation::build(&result.findings); + let mut out = String::new(); + + out.push_str(&format!(" {}\n", "Remediation priority".bold())); + out.push_str(&format!( + " {} {} fix now {} {} schedule next {} {} monitor\n", + "■".red().bold(), + plan.fix_now, + "■".yellow().bold(), + plan.schedule_next, + "■".dimmed(), + plan.monitor + )); + + for (index, action) in plan.top_actions.iter().take(3).enumerate() { + out.push_str(&format!( + " {}. {} {} {} ({} affected)\n", + index + 1, + remediation::lane_label(action.lane).bold(), + action.rule_id.dimmed(), + action.title, + action.affected + )); + out.push_str(&format!(" {} {}\n", "Fix:".green().bold(), action.fix)); + } + + out +} + fn render_severity_summary(result: &ScanResult) -> String { let critical = result .findings @@ -486,6 +521,7 @@ mod tests { }; let output = render(&result); assert!(output.contains("CRITICAL")); + assert!(output.contains("Remediation priority")); assert!(output.contains("Test finding")); assert!(output.contains("Test fix")); } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index b3f8857..50a6a0d 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -186,6 +186,42 @@ fn test_json_output() { let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert!(parsed["findings"].is_array()); assert!(parsed["score"].is_number()); + assert!(parsed["remediation_plan"].is_object()); + assert!(parsed["remediation_plan"]["fix_now"].as_u64().unwrap() > 0); + assert!(parsed["remediation_plan"]["top_actions"].is_array()); +} + +#[test] +fn test_markdown_output_includes_remediation_priority() { + let output = agentwise() + .args([ + "scan", + "testdata/vulnerable-mcp.json", + "--format", + "markdown", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("## Remediation priority")); + assert!(stdout.contains("| Fix now |")); + assert!(stdout.contains("| Priority | Rule | Action | Affected |")); +} + +#[test] +fn test_html_output_includes_remediation_priority() { + let output = agentwise() + .args(["scan", "testdata/vulnerable-mcp.json", "--format", "html"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Remediation priority")); + assert!(stdout.contains("priority-lane")); + assert!(stdout.contains("action-row")); } #[test]