use std::fmt::Write;
use crate::config::Config;
use crate::models::{Finding, Severity, SuspectValue};
fn truncate_str(s: &str, max_chars: usize) -> String {
let truncated: String = s.chars().take(max_chars).collect();
if truncated.len() < s.len() {
format!("{}...", truncated)
} else {
truncated
}
}
pub struct MarkdownFormatter {
show_scores: bool,
show_remediation: bool,
}
impl MarkdownFormatter {
pub fn new(config: &Config) -> Self {
Self {
show_scores: config.output.show_scores,
show_remediation: config.output.show_remediation,
}
}
pub fn default_formatter() -> Self {
Self {
show_scores: false,
show_remediation: true,
}
}
pub fn format(&self, findings: &[Finding]) -> String {
let mut output = String::new();
writeln!(output, "# Secretrace Report\n").unwrap();
writeln!(output, "## Summary\n").unwrap();
writeln!(output, "- **Total findings**: {}", findings.len()).unwrap();
let critical = findings
.iter()
.filter(|f| f.score.severity == Severity::Critical)
.count();
let high = findings
.iter()
.filter(|f| f.score.severity == Severity::High)
.count();
let medium = findings
.iter()
.filter(|f| f.score.severity == Severity::Medium)
.count();
let low = findings
.iter()
.filter(|f| f.score.severity == Severity::Low)
.count();
if critical > 0 {
writeln!(output, "- **Critical**: {}", critical).unwrap();
}
if high > 0 {
writeln!(output, "- **High**: {}", high).unwrap();
}
if medium > 0 {
writeln!(output, "- **Medium**: {}", medium).unwrap();
}
if low > 0 {
writeln!(output, "- **Low**: {}", low).unwrap();
}
writeln!(output).unwrap();
if findings.is_empty() {
writeln!(output, "No findings detected.").unwrap();
return output;
}
writeln!(output, "## Findings\n").unwrap();
writeln!(output, "| Severity | File | Line | Name | Description |").unwrap();
writeln!(output, "|----------|------|------|------|-------------|").unwrap();
for finding in findings {
let severity_badge = match finding.score.severity {
Severity::Critical => "🔴 Critical",
Severity::High => "🟠 High",
Severity::Medium => "🟡 Medium",
Severity::Low => "🟢 Low",
Severity::Info => "ℹ️ Info",
Severity::None => "⚪ None",
};
let name = match &finding.suspect {
SuspectValue::Constant { name, .. } => name.clone(),
SuspectValue::Static { name, .. } => name.clone(),
SuspectValue::InlineLiteral { value } => format!("\"{}\"", truncate_str(value, 20)),
SuspectValue::EnvDefault { var_name, .. } => var_name.clone(),
};
let description = finding.explanation.replace('|', "\\|");
let description = truncate_str(&description, 60);
writeln!(
output,
"| {} | `{}` | {} | `{}` | {} |",
severity_badge,
finding.location.file.display(),
finding.location.line,
name,
description
)
.unwrap();
}
writeln!(output, "\n## Details\n").unwrap();
for (i, finding) in findings.iter().enumerate() {
self.format_finding_detail(&mut output, finding, i + 1);
}
output
}
fn format_finding_detail(&self, output: &mut String, finding: &Finding, index: usize) {
let severity = match finding.score.severity {
Severity::Critical => "Critical",
Severity::High => "High",
Severity::Medium => "Medium",
Severity::Low => "Low",
Severity::Info => "Info",
Severity::None => "None",
};
writeln!(output, "### {}. {} ({})\n", index, finding.id.0, severity).unwrap();
writeln!(
output,
"**Location**: `{}:{}:{}`\n",
finding.location.file.display(),
finding.location.line,
finding.location.column
)
.unwrap();
match &finding.suspect {
SuspectValue::Constant { name, value, .. } => {
writeln!(output, "**Type**: Constant\n").unwrap();
writeln!(output, "```rust").unwrap();
writeln!(output, "const {}: &str = \"{}\";", name, truncate(value, 80)).unwrap();
writeln!(output, "```\n").unwrap();
}
SuspectValue::Static { name, value, .. } => {
writeln!(output, "**Type**: Static\n").unwrap();
writeln!(output, "```rust").unwrap();
writeln!(output, "static {}: &str = \"{}\";", name, truncate(value, 80)).unwrap();
writeln!(output, "```\n").unwrap();
}
SuspectValue::InlineLiteral { value } => {
writeln!(output, "**Type**: Inline Literal\n").unwrap();
writeln!(output, "```rust").unwrap();
writeln!(output, "\"{}\"", truncate(value, 80)).unwrap();
writeln!(output, "```\n").unwrap();
}
SuspectValue::EnvDefault {
var_name,
default_value,
} => {
writeln!(output, "**Type**: Environment Variable Default\n").unwrap();
writeln!(output, "Variable: `{}`\n", var_name).unwrap();
writeln!(output, "Default: `\"{}\"`\n", truncate(default_value, 80)).unwrap();
}
}
writeln!(output, "**Explanation**: {}\n", finding.explanation).unwrap();
if self.show_scores {
writeln!(output, "**Score**: {}\n", finding.score.total).unwrap();
if !finding.score.factors.is_empty() {
writeln!(output, "**Score Factors**:\n").unwrap();
for factor in &finding.score.factors {
if factor.contribution != 0 {
let sign = if factor.contribution > 0 { "+" } else { "" };
writeln!(output, "- {}{}: {}", sign, factor.contribution, factor.reason)
.unwrap();
}
}
writeln!(output).unwrap();
}
}
if self.show_remediation {
if let Some(ref remediation) = finding.remediation {
writeln!(output, "**Remediation**: {}\n", remediation).unwrap();
}
}
writeln!(output, "---\n").unwrap();
}
}
fn truncate(s: &str, max_chars: usize) -> String {
let truncated: String = s.chars().take(max_chars).collect();
if truncated.len() < s.len() {
format!("{}...", truncated)
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{FindingId, Score, SourceLocation};
use std::path::PathBuf;
fn create_test_finding() -> Finding {
Finding {
id: FindingId::new("test-finding-1"),
suspect: SuspectValue::Constant {
name: "API_KEY".to_string(),
value: "sk_live_12345".to_string(),
type_annotation: Some("&str".to_string()),
},
location: SourceLocation::new(PathBuf::from("src/main.rs"), 10, 5),
usage: None,
context: Default::default(),
score: Score::from_total(85),
explanation: "Test explanation".to_string(),
remediation: Some("Test remediation".to_string()),
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn test_markdown_format_empty() {
let formatter = MarkdownFormatter::default_formatter();
let output = formatter.format(&[]);
assert!(output.contains("# Secretrace Report"));
assert!(output.contains("**Total findings**: 0"));
assert!(output.contains("No findings detected."));
}
#[test]
fn test_markdown_format_single_finding() {
let formatter = MarkdownFormatter::default_formatter();
let finding = create_test_finding();
let output = formatter.format(&[finding]);
assert!(output.contains("# Secretrace Report"));
assert!(output.contains("**Total findings**: 1"));
assert!(output.contains("API_KEY"));
assert!(output.contains("src/main.rs"));
}
#[test]
fn test_markdown_has_table() {
let formatter = MarkdownFormatter::default_formatter();
let finding = create_test_finding();
let output = formatter.format(&[finding]);
assert!(output.contains("| Severity |"));
assert!(output.contains("|----------|"));
}
#[test]
fn test_markdown_has_details() {
let formatter = MarkdownFormatter::default_formatter();
let finding = create_test_finding();
let output = formatter.format(&[finding]);
assert!(output.contains("## Details"));
assert!(output.contains("**Location**:"));
assert!(output.contains("**Explanation**:"));
}
}