Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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
Expand All @@ -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.<name>]` 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

<p align="center">
Expand Down
103 changes: 103 additions & 0 deletions src/report/html.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::report::remediation::{self, RemediationPlan};
use crate::rules::{Finding, Severity};
use crate::scanner::ScanResult;
use std::f64::consts::PI;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -401,6 +457,11 @@ pub fn render(result: &ScanResult) -> String {
out.push_str(&severity_chart);
out.push_str(" </section>\n");

out.push_str(" <section class=\"card\">\n");
out.push_str(" <h2 class=\"section-title\">Remediation priority</h2>\n");
out.push_str(&remediation_html);
out.push_str(" </section>\n");

out.push_str(" <section>\n");
out.push_str(" <h2 class=\"section-title\">Findings</h2>\n");
out.push_str(" <div class=\"findings-grid\">\n");
Expand All @@ -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(" <div class=\"remediation-grid\">\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(" </div>\n");

if plan.top_actions.is_empty() {
out.push_str(" <p class=\"empty-state\">No remediation action needed.</p>\n");
return out;
}

out.push_str(" <div class=\"action-list\">\n");
for action in &plan.top_actions {
let _ = writeln!(
out,
" <div class=\"action-row\"><div class=\"action-head\"><span class=\"pill {}\">{}</span><span class=\"rule-id\">{} affected</span></div><div class=\"action-title\">{}: {}</div><p class=\"action-fix\">{}</p></div>",
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(" </div>\n");

out
}

fn append_priority_lane(out: &mut String, label: &str, value: usize) {
let _ = writeln!(
out,
" <div class=\"priority-lane\"><div class=\"priority-label\">{}</div><div class=\"priority-value\">{}</div></div>",
escape_html(label),
value
);
}

fn append_stat<T: std::fmt::Display>(out: &mut String, label: &str, value: T) {
let _ = writeln!(
out,
Expand Down Expand Up @@ -686,6 +788,7 @@ mod tests {
let html = render(&result);
assert!(html.contains("<!DOCTYPE html>"));
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{}",
Expand Down
9 changes: 9 additions & 0 deletions src/report/json.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::report::remediation::{self, RemediationPlan};
use crate::scanner::ScanResult;
use serde::Serialize;

Expand All @@ -12,6 +13,7 @@ struct JsonReport<'a> {
suppressed_count: usize,
findings: &'a [crate::rules::Finding],
summary: Summary,
remediation_plan: RemediationPlan,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -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"),
Expand All @@ -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()
Expand Down Expand Up @@ -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);
}

Expand Down
37 changes: 37 additions & 0 deletions src/report/markdown.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::report::remediation;
use crate::rules::{Finding, Severity};
use crate::scanner::ScanResult;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 => "✖",
Expand Down Expand Up @@ -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:**"));
Expand Down
1 change: 1 addition & 0 deletions src/report/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod html;
pub mod json;
pub mod markdown;
pub mod remediation;
pub mod sarif;
pub mod terminal;

Expand Down
Loading
Loading