sentio-cli 0.1.3

AST-based security scanner for Solana/Anchor programs
Documentation
use sentio_core::{RuleRegistry, ScanResult, Severity};
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};

pub fn render_human_report<W: Write>(
    result: &ScanResult,
    registry: &RuleRegistry,
    mut writer: W,
    use_color: bool,
) -> io::Result<()> {
    if !result.parse_failures.is_empty() {
        writeln!(
            writer,
            "{}",
            colorize("==============PARSE FAILURES==============", "1;31", use_color)
        )?;
        for failure in &result.parse_failures {
            writeln!(writer, "{}\n  {}", failure.path, failure.message)?;
        }
        writeln!(writer)?;
    }

    if result.findings.is_empty() {
        if result.parse_failures.is_empty() {
            writeln!(writer, "No findings.")?;
        } else {
            writeln!(writer, "No findings in successfully parsed files.")?;
        }
        return Ok(());
    }

    for (index, finding) in result.findings.iter().enumerate() {
        let meta = lookup_metadata(registry, &finding.rule_id);
        let title = meta.map(|item| item.title).unwrap_or("Unknown rule");
        let description = meta.map(|item| item.description);
        let guidance = finding
            .help
            .as_deref()
            .or_else(|| meta.map(|item| item.fix_guidance));
        let severity = severity_label(finding.severity);
        let severity_color = severity_ansi(finding.severity);
        let banner = format!(
            "==============FINDING {}: {} {}==============",
            index + 1,
            finding.rule_id,
            title
        );

        writeln!(writer, "{}", colorize(&banner, severity_color, use_color))?;
        writeln!(
            writer,
            "{} {}",
            colorize("Severity:", "1;37", use_color),
            colorize(severity, severity_color, use_color)
        )?;
        writeln!(
            writer,
            "{} {}:{}:{}",
            colorize("Location:", "1;36", use_color),
            finding.location.path,
            finding.location.line,
            finding.location.column
        )?;
        writeln!(writer)?;

        if let Some(description) = description {
            writeln!(writer, "{}", colorize("Rule:", "1;36", use_color))?;
            writeln!(writer, "  {description}")?;
            writeln!(writer)?;
        }

        writeln!(writer, "{}", colorize("Matched Because:", "1;36", use_color))?;
        writeln!(writer, "  {}", finding.message)?;
        writeln!(writer)?;

        writeln!(writer, "{}", colorize("Source:", "1;36", use_color))?;
        match format_source_excerpt(
            &finding.location.path,
            finding.location.line,
            finding.location.column,
            2,
            severity_color,
            use_color,
        ) {
            Some(excerpt) => write!(writer, "{excerpt}")?,
            None => writeln!(writer, "  Source excerpt unavailable.")?,
        }
        writeln!(writer)?;

        if let Some(guidance) = guidance {
            writeln!(writer, "{}", colorize("Guidance:", "1;36", use_color))?;
            writeln!(writer, "  {guidance}")?;
            writeln!(writer)?;
        }
    }

    write_summary(&mut writer, result, registry, use_color)?;
    Ok(())
}

pub fn format_source_excerpt(
    path: &str,
    line: usize,
    column: usize,
    radius: usize,
    highlight_color: &str,
    use_color: bool,
) -> Option<String> {
    let source = fs::read_to_string(path).ok()?;
    let lines: Vec<&str> = source.lines().collect();
    if lines.is_empty() {
        return None;
    }

    let hit_index = line.saturating_sub(1).min(lines.len().saturating_sub(1));
    let start = hit_index.saturating_sub(radius);
    let end = (hit_index + radius).min(lines.len().saturating_sub(1));
    let width = (end + 1).to_string().len();
    let mut output = String::new();

    for current in start..=end {
        let marker = if current == hit_index { '>' } else { ' ' };
        let source_line = format!(
            " {marker}{:>width$}| {}\n",
            current + 1,
            lines[current],
            width = width
        );

        if current == hit_index {
            if use_color {
                output.push_str(&colorize(&source_line, highlight_color, true));
            } else {
                output.push_str(&source_line);
            }
            let caret_indent = " ".repeat(column.saturating_sub(1));
            let caret_line = format!("  {:>width$}| {caret_indent}^\n", "", width = width);
            if use_color {
                output.push_str(&colorize(&caret_line, highlight_color, true));
            } else {
                output.push_str(&caret_line);
            }
        } else {
            output.push_str(&source_line);
        }
    }

    Some(output)
}

fn write_summary<W: Write>(
    writer: &mut W,
    result: &ScanResult,
    registry: &RuleRegistry,
    use_color: bool,
) -> io::Result<()> {
    let mut rule_counts: BTreeMap<String, usize> = 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,
        }
    }

    writeln!(writer, "{}", colorize("-------- Summary --------", "1;36", use_color))?;
    writeln!(writer, "Total findings: {}", result.findings.len())?;
    writeln!(
        writer,
        "{} {}",
        colorize("Critical:", "1;37", use_color),
        colorize(&critical.to_string(), "1;31", use_color)
    )?;
    writeln!(
        writer,
        "{} {}",
        colorize("High:", "1;37", use_color),
        colorize(&high.to_string(), "31", use_color)
    )?;
    writeln!(
        writer,
        "{} {}",
        colorize("Medium:", "1;37", use_color),
        colorize(&medium.to_string(), "33", use_color)
    )?;
    writeln!(
        writer,
        "{} {}",
        colorize("Low:", "1;37", use_color),
        colorize(&low.to_string(), "32", use_color)
    )?;
    writeln!(writer)?;
    writeln!(writer, "{}", colorize("By rule:", "1;36", use_color))?;

    for (rule_id, count) in rule_counts {
        let title = lookup_metadata(registry, &rule_id)
            .map(|item| item.title)
            .unwrap_or("Unknown rule");
        writeln!(writer, "  {count}  {rule_id} {title}")?;
    }

    Ok(())
}

pub fn lookup_metadata<'a>(
    registry: &'a RuleRegistry,
    rule_id: &str,
) -> Option<&'a sentio_core::RuleMetadata> {
    registry
        .all()
        .iter()
        .find(|rule| rule.metadata().id.eq_ignore_ascii_case(rule_id))
        .map(|rule| rule.metadata())
}

pub fn severity_label(severity: Severity) -> &'static str {
    match severity {
        Severity::Low => "low",
        Severity::Medium => "medium",
        Severity::High => "high",
        Severity::Critical => "critical",
    }
}

pub fn severity_ansi(severity: Severity) -> &'static str {
    match severity {
        Severity::Critical => "1;31",
        Severity::High => "31",
        Severity::Medium => "33",
        Severity::Low => "32",
    }
}

pub fn colorize(text: &str, ansi_code: &str, enabled: bool) -> String {
    if enabled {
        format!("\x1b[{ansi_code}m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}