diff --git a/Cargo.lock b/Cargo.lock index 3a473e4..6048c06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "sentio-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", @@ -600,7 +600,7 @@ dependencies = [ [[package]] name = "sentio-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9181971..1e82354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/sentio-security/sentio-rs" diff --git a/README.md b/README.md index 6e02cf3..f8b4575 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ sentio scan . --format sarif --output sentio.sarif --fail-on high # JSON for tooling / agents sentio scan . --format json --output report.json +# Markdown for docs / Discord / Notion +sentio scan . --format markdown --output report.md + # One rule only sentio scan . --rule SW003 @@ -72,8 +75,8 @@ Arguments: [PATH] Directory or .rs file to scan [default: .] Options: - --format human (default) | json | sarif - --output Write json/sarif output to a file + --format human (default) | json | sarif | markdown + --output Write json/sarif/markdown output to a file --rule Run only a specific rule, e.g. --rule SW003 --include-tests Include test files (excluded by default) --config Path to sentio.toml diff --git a/crates/sentio-cli/Cargo.toml b/crates/sentio-cli/Cargo.toml index 2bc0a8e..f16a323 100644 --- a/crates/sentio-cli/Cargo.toml +++ b/crates/sentio-cli/Cargo.toml @@ -21,4 +21,4 @@ dirs.workspace = true serde_json.workspace = true ureq.workspace = true uuid.workspace = true -sentio-core = { path = "../sentio-core", version = "0.3.0" } +sentio-core = { path = "../sentio-core", version = "0.3.1" } diff --git a/crates/sentio-cli/src/lib.rs b/crates/sentio-cli/src/lib.rs index b9d78bc..3bcb413 100644 --- a/crates/sentio-cli/src/lib.rs +++ b/crates/sentio-cli/src/lib.rs @@ -115,6 +115,111 @@ pub fn render_human_report( Ok(()) } +/// Markdown format! +/// No source excerpts by default (keeps reports small); includes summary + findings. +pub fn render_markdown_report(result: &ScanResult, registry: &RuleRegistry) -> String { + let mut out = String::new(); + + out.push_str("# sentio report\n\n"); + + if !result.parse_failures.is_empty() { + out.push_str("## Parse failures\n\n"); + for failure in &result.parse_failures { + out.push_str(&format!("- `{}`: {}\n", failure.path, failure.message)); + } + out.push('\n'); + } + + // Summary first (what people paste most often). + let mut rule_counts: BTreeMap = BTreeMap::new(); + let mut critical = 0usize; + let mut high = 0usize; + let mut medium = 0usize; + let mut low = 0usize; + for finding in &result.findings { + *rule_counts.entry(finding.rule_id.clone()).or_default() += 1; + match finding.severity { + Severity::Critical => critical += 1, + Severity::High => high += 1, + Severity::Medium => medium += 1, + Severity::Low => low += 1, + } + } + + out.push_str("## Summary\n\n"); + out.push_str(&format!("- **Total:** {}\n", result.findings.len())); + out.push_str(&format!("- **Critical:** {critical}\n")); + out.push_str(&format!("- **High:** {high}\n")); + out.push_str(&format!("- **Medium:** {medium}\n")); + out.push_str(&format!("- **Low:** {low}\n")); + if result.baselined_count > 0 { + out.push_str(&format!( + "- **Baselined (hidden):** {}\n", + result.baselined_count + )); + } + out.push_str(&format!( + "- **Files scanned / parsed:** {} / {}\n\n", + result.files_scanned, result.files_parsed + )); + + if !rule_counts.is_empty() { + out.push_str("### By rule\n\n"); + out.push_str("| Count | Rule | Title |\n"); + out.push_str("|------:|------|-------|\n"); + for (rule_id, count) in &rule_counts { + let title = lookup_metadata(registry, rule_id) + .map(|m| m.title) + .unwrap_or("Unknown rule"); + out.push_str(&format!("| {count} | `{rule_id}` | {title} |\n")); + } + out.push('\n'); + } + + if result.findings.is_empty() { + if result.baselined_count > 0 { + out.push_str("No new findings.\n"); + } else if result.parse_failures.is_empty() { + out.push_str("No findings.\n"); + } else { + out.push_str("No findings in successfully parsed files.\n"); + } + return out; + } + + out.push_str("## Findings\n\n"); + for (index, finding) in result.findings.iter().enumerate() { + let meta = lookup_metadata(registry, &finding.rule_id); + let title = meta.map(|m| m.title).unwrap_or("Unknown rule"); + let guidance = finding + .help + .as_deref() + .or_else(|| meta.map(|m| m.fix_guidance)); + + out.push_str(&format!( + "### {}. `{}` — {}\n\n", + index + 1, + finding.rule_id, + title + )); + out.push_str(&format!( + "- **Severity:** {}\n", + severity_label(finding.severity) + )); + out.push_str(&format!( + "- **Location:** `{}:{}:{}`\n", + finding.location.path, finding.location.line, finding.location.column + )); + out.push_str(&format!("- **Matched because:** {}\n", finding.message)); + if let Some(g) = guidance { + out.push_str(&format!("- **Guidance:** {g}\n")); + } + out.push('\n'); + } + + out +} + pub fn format_source_excerpt( path: &str, line: usize, diff --git a/crates/sentio-cli/src/main.rs b/crates/sentio-cli/src/main.rs index 6b1cb0a..df8e6dd 100644 --- a/crates/sentio-cli/src/main.rs +++ b/crates/sentio-cli/src/main.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Context, Result}; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; -use sentio_cli::render_human_report; +use sentio_cli::{render_human_report, render_markdown_report}; use sentio_core::{ resolve_config_path, to_sarif_json, Baseline, FailOn, RuleRegistry, ScanOptions, ScanResult, Scanner, SentioConfig, Severity, @@ -84,7 +84,7 @@ struct ScanArgs { fn run_scan(args: ScanArgs) -> Result { if args.output.is_some() && matches!(args.format, OutputFormat::Human) { - bail!("--output requires --format json or --format sarif"); + bail!("--output requires --format json, sarif, or markdown"); } let scan_path = PathBuf::from(&args.path); @@ -151,6 +151,10 @@ fn run_scan(args: ScanArgs) -> Result { .map_err(|e| anyhow::anyhow!(e))?; write_or_print(&args.output, &sarif)?; } + OutputFormat::Markdown => { + let md = render_markdown_report(&result, ®istry); + write_or_print(&args.output, &md)?; + } } Ok(exit_code_for(&result, fail_on)) @@ -302,7 +306,7 @@ enum Commands { #[arg(long)] include_tests: bool, - /// Write output to a file instead of stdout (json or sarif) + /// Write output to a file instead of stdout (json, sarif, or markdown) #[arg(long, value_name = "FILE")] output: Option, @@ -339,6 +343,8 @@ enum OutputFormat { Json, /// SARIF 2.1.0 — for GitHub Code Scanning and security dashboards Sarif, + /// Markdown report + Markdown, } #[derive(Debug, Subcommand)] diff --git a/crates/sentio-cli/tests/human_output.rs b/crates/sentio-cli/tests/human_output.rs index a6b131e..490805d 100644 --- a/crates/sentio-cli/tests/human_output.rs +++ b/crates/sentio-cli/tests/human_output.rs @@ -1,4 +1,4 @@ -use sentio_cli::{format_source_excerpt, render_human_report}; +use sentio_cli::{format_source_excerpt, render_human_report, render_markdown_report}; use sentio_core::{Finding, RuleRegistry, ScanResult, Severity, SourceLocation}; use std::fs; use std::path::PathBuf; @@ -115,3 +115,33 @@ fn renders_human_report_with_ansi_color_when_enabled() { fs::remove_file(path).expect("temp file should be removed"); } + +#[test] +fn renders_markdown_report() { + let result = ScanResult { + findings: vec![Finding { + rule_id: "SW016".to_string(), + severity: Severity::Medium, + message: "Account `vault` uses `init_if_needed`.".to_string(), + location: SourceLocation { + path: "src/lib.rs".to_string(), + line: 4, + column: 1, + }, + help: Some("Prefer init when possible.".to_string()), + suppressed: false, + }], + files_scanned: 1, + files_parsed: 1, + parse_failures: Vec::new(), + baselined_count: 0, + }; + + let md = render_markdown_report(&result, &RuleRegistry::baseline()); + assert!(md.contains("# sentio report")); + assert!(md.contains("## Summary")); + assert!(md.contains("`SW016`")); + assert!(md.contains("**Severity:** medium")); + assert!(md.contains("src/lib.rs:4:1")); + assert!(md.contains("Prefer init when possible.")); +}