tirith 0.3.0

Terminal security - catches homograph attacks, pipe-to-shell, ANSI injection
use tirith_core::rule_explanations::{self, RuleExplanation};

pub fn run(rule: Option<&str>, list: bool, category: Option<&str>, json: bool) -> i32 {
    if list {
        return run_list(category, json);
    }

    match rule {
        Some(id) => run_single(id, json),
        None => {
            eprintln!("tirith: specify --rule <id> or --list");
            1
        }
    }
}

fn run_single(id: &str, json: bool) -> i32 {
    let Some(entry) = rule_explanations::explain(id) else {
        eprintln!("tirith: unknown rule: {id}");
        if let Some(suggestion) = suggest(id) {
            eprintln!("  did you mean: {suggestion}?");
        }
        return 1;
    };

    if json {
        print_json(entry);
    } else {
        print_human_single(entry);
    }
    0
}

fn run_list(category: Option<&str>, json: bool) -> i32 {
    let entries: Vec<&RuleExplanation> = match category {
        Some(cat) => {
            let list = rule_explanations::list_by_category(cat);
            if list.is_empty() {
                eprintln!("tirith: unknown category: {cat}");
                let cats = rule_explanations::categories();
                eprintln!("  valid categories: {}", cats.join(", "));
                return 1;
            }
            list
        }
        None => rule_explanations::list_all().iter().collect(),
    };

    if json {
        print_json(&entries);
    } else {
        print_human_list(&entries);
    }
    0
}

fn print_human_single(e: &RuleExplanation) {
    println!("{}{}  [{}]", e.id, e.title, e.category);
    println!("Severity: {}", e.severity_rationale);
    println!();
    println!("Description");
    println!("  {}", e.description);

    if !e.examples_bad.is_empty() {
        println!();
        println!("Examples (flagged)");
        for ex in e.examples_bad {
            println!("  {ex}");
        }
    }

    if !e.examples_good.is_empty() {
        println!();
        println!("Examples (safe)");
        for ex in e.examples_good {
            println!("  {ex}");
        }
    }

    println!();
    println!("False positives");
    println!("  {}", e.false_positive_guidance);

    println!();
    println!("Remediation");
    println!("  {}", e.remediation);

    if let Some(mitre) = e.mitre_id {
        println!();
        println!("MITRE ATT&CK: {mitre}");
    }

    if !e.references.is_empty() {
        println!();
        println!("References");
        for r in e.references {
            println!("  {r}");
        }
    }
}

fn print_human_list(entries: &[&RuleExplanation]) {
    let mut current_category = "";
    for e in entries {
        if e.category != current_category {
            if !current_category.is_empty() {
                println!();
            }
            println!("=== {} ===", e.category.to_uppercase());
            current_category = e.category;
        }
        println!("  {:<40} {}", e.id, e.title);
    }
}

fn print_json(value: &impl serde::Serialize) {
    if let Err(e) = serde_json::to_writer_pretty(std::io::stdout().lock(), value) {
        eprintln!("tirith: failed to write JSON output: {e}");
    }
    println!();
}

fn suggest(query: &str) -> Option<&'static str> {
    let mut best_id = "";
    let mut best_dist = usize::MAX;

    for entry in rule_explanations::list_all() {
        let d = tirith_core::util::levenshtein(query, entry.id);
        if d < best_dist {
            best_dist = d;
            best_id = entry.id;
        }
    }

    if best_dist <= 3 {
        Some(best_id)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_suggest_close_typo() {
        let s = suggest("pipe_to_interpeter");
        assert_eq!(s, Some("pipe_to_interpreter"));
    }

    #[test]
    fn test_suggest_no_match() {
        let s = suggest("zzzzzzzzzzzzz");
        assert!(s.is_none());
    }
}