keyhog 0.5.37

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
//! Logic for the `detectors` subcommand.

mod brace_rewrite;

use brace_rewrite::{
    fix_single_brace_in_verify_blocks, rewrite_braces, rewrite_braces_in_string_literals,
};

use crate::args::DetectorArgs;
use anyhow::{Context, Result};
use keyhog_core::{validate_detector, DetectorFile, DetectorSpec, QualityIssue};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// Exit code for `detectors --audit` when one or more `Error`-severity
/// issues were found. Distinct from the scan exit codes (10 = live
/// credentials, 11 = scanner panic) so a CI gate can treat detector
/// quality as its own signal.
const EXIT_AUDIT_FAILED: u8 = 3;

pub fn run(args: DetectorArgs) -> Result<ExitCode> {
    if args.fix {
        return run_fix(&args);
    }
    if args.audit {
        return run_audit(&args);
    }
    run_list(args)?;
    Ok(ExitCode::SUCCESS)
}

fn run_list(args: DetectorArgs) -> Result<()> {
    let detectors = if args.detectors.exists() && args.detectors.is_dir() {
        keyhog_core::load_detectors(&args.detectors)?
    } else {
        load_embedded_or_bail(&args.detectors)?
    };
    let source = if args.detectors.exists() {
        format!("{}", args.detectors.display())
    } else {
        "embedded".to_string()
    };

    // Apply --search filter case-insensitively against the four most useful
    // fields. The 888-strong corpus is otherwise hard to navigate by eye -
    // `keyhog detectors --search aws` should beat `grep -r aws detectors/`.
    fn contains_ci(haystack: &str, needle: &[u8]) -> bool {
        if needle.is_empty() || needle.len() > haystack.len() {
            return needle.is_empty();
        }
        haystack
            .as_bytes()
            .windows(needle.len())
            .any(|w| w.eq_ignore_ascii_case(needle))
    }
    let needle: Option<Vec<u8>> = args.search.as_ref().map(|s| s.as_bytes().to_vec());
    let filtered: Vec<&DetectorSpec> = detectors
        .iter()
        .filter(|d| match needle.as_deref() {
            None => true,
            Some(q) => {
                contains_ci(&d.id, q)
                    || contains_ci(&d.name, q)
                    || contains_ci(&d.service, q)
                    || d.keywords.iter().any(|k| contains_ci(k, q))
            }
        })
        .collect();

    if args.json {
        print_detectors_json(&filtered)?;
        return Ok(());
    }

    if let Some(q) = args.search.as_deref() {
        println!(
            "Loaded {} detectors ({source}); {} match '{q}':",
            detectors.len(),
            filtered.len()
        );
    } else {
        println!("Loaded {} detectors ({source}):", detectors.len());
    }

    if args.verbose {
        for d in &filtered {
            print_detector_verbose(d);
        }
        return Ok(());
    }

    let mut by_service: std::collections::BTreeMap<String, Vec<&str>> =
        std::collections::BTreeMap::new();
    for d in &filtered {
        by_service
            .entry(d.service.clone())
            .or_default()
            .push(d.id.as_str());
    }

    for (service, ids) in &by_service {
        println!("  - {} ({} detectors)", service, ids.len());
        for id in ids {
            println!("    - {}", id);
        }
    }

    Ok(())
}

/// Programmatic detector discovery: emits a JSON array on stdout in
/// schema-stable form. Same field shape as `print_detector_verbose`
/// so the human and machine views stay in sync.
fn print_detectors_json(detectors: &[&DetectorSpec]) -> Result<()> {
    use serde_json::{json, Value};
    let items: Vec<Value> = detectors
        .iter()
        .map(|d| {
            let patterns: Vec<Value> = d
                .patterns
                .iter()
                .map(|p| {
                    json!({
                        "regex": p.regex,
                        "description": p.description,
                        "group": p.group,
                    })
                })
                .collect();
            let companions: Vec<Value> = d
                .companions
                .iter()
                .map(|c| {
                    json!({
                        "name": c.name,
                        "regex": c.regex,
                        "within_lines": c.within_lines,
                        "required": c.required,
                    })
                })
                .collect();
            json!({
                "id": d.id,
                "name": d.name,
                "service": d.service,
                "severity": format!("{:?}", d.severity),
                "keywords": d.keywords,
                "patterns": patterns,
                "companions": companions,
                "verify": d.verify.is_some(),
            })
        })
        .collect();
    let out =
        serde_json::to_string_pretty(&items).context("serializing detector listing to JSON")?;
    println!("{out}");
    Ok(())
}

fn load_embedded_or_bail(detectors_path: &Path) -> Result<Vec<DetectorSpec>> {
    let embedded = keyhog_core::embedded_detector_tomls();
    if embedded.is_empty() {
        anyhow::bail!(
            "detector directory '{}' not found and no embedded detectors available. \
             Fix: rebuild with detectors/ directory or specify --detectors <path>",
            detectors_path.display()
        );
    }
    let mut dets = Vec::new();
    for (name, toml_content) in embedded {
        match toml::from_str::<DetectorFile>(toml_content) {
            Ok(file) => dets.push(file.detector),
            Err(e) => eprintln!("warning: failed to parse embedded detector {name}: {e}"),
        }
    }
    Ok(dets)
}

fn run_audit(args: &DetectorArgs) -> Result<ExitCode> {
    let detectors = if args.detectors.exists() && args.detectors.is_dir() {
        keyhog_core::load_detectors(&args.detectors)?
    } else {
        load_embedded_or_bail(&args.detectors)?
    };

    let mut total_errors = 0usize;
    let mut total_warnings = 0usize;
    let mut affected = 0usize;

    for d in &detectors {
        let issues = validate_detector(d);
        if issues.is_empty() {
            continue;
        }
        affected += 1;
        let (e, w): (usize, usize) = issues
            .iter()
            .map(|i| match i {
                QualityIssue::Error(_) => (1, 0),
                QualityIssue::Warning(_) => (0, 1),
            })
            .fold((0, 0), |a, b| (a.0 + b.0, a.1 + b.1));
        total_errors += e;
        total_warnings += w;
        println!("\n  {} ({} error(s), {} warning(s))", d.id, e, w);
        for issue in issues {
            match issue {
                QualityIssue::Error(m) => println!("    \x1b[31mERROR\x1b[0m: {m}"),
                QualityIssue::Warning(m) => println!("    \x1b[33mWARN\x1b[0m:  {m}"),
            }
        }
    }

    println!(
        "\nAudit complete: {} detector(s) checked, {} affected, {} error(s), {} warning(s).",
        detectors.len(),
        affected,
        total_errors,
        total_warnings
    );

    if total_errors > 0 {
        Ok(ExitCode::from(EXIT_AUDIT_FAILED))
    } else {
        Ok(ExitCode::SUCCESS)
    }
}

fn run_fix(args: &DetectorArgs) -> Result<ExitCode> {
    if !args.detectors.exists() || !args.detectors.is_dir() {
        anyhow::bail!(
            "--fix requires a real detectors directory; '{}' does not exist or is not a directory. \
             Embedded detectors are immutable: clone the detectors/ tree from the repo and pass \
             --detectors <DIR>.",
            args.detectors.display()
        );
    }

    let entries = list_toml_files(&args.detectors)?;
    if entries.is_empty() {
        anyhow::bail!(
            "no .toml files found under '{}'. Are you pointing at the right directory?",
            args.detectors.display()
        );
    }

    let mut total_files = 0usize;
    let mut files_changed = 0usize;
    let mut total_rewrites = 0usize;

    for entry in entries {
        total_files += 1;
        let raw = std::fs::read_to_string(&entry)
            .with_context(|| format!("reading {}", entry.display()))?;
        let (rewritten, count) = fix_single_brace_in_verify_blocks(&raw);
        if count == 0 {
            continue;
        }
        // Re-validate by parsing the rewritten content. If serde rejects
        // it (we corrupted the TOML), back off rather than save garbage.
        if toml::from_str::<DetectorFile>(&rewritten).is_err() {
            eprintln!(
                "warn: skipping {}: rewrite produced invalid TOML; please file a bug",
                entry.display()
            );
            continue;
        }
        files_changed += 1;
        total_rewrites += count;
        if args.dry_run {
            println!(
                "would fix {}: {} single-brace → double-brace rewrite(s)",
                entry.display(),
                count
            );
        } else {
            atomic_write(&entry, &rewritten)
                .with_context(|| format!("writing fixed {}", entry.display()))?;
            println!("fixed {}: {} rewrite(s)", entry.display(), count);
        }
    }

    if args.dry_run {
        println!(
            "\nDry-run complete: {} file(s) inspected, {} would change, {} total rewrite(s).",
            total_files, files_changed, total_rewrites
        );
    } else {
        println!(
            "\nFix complete: {} file(s) inspected, {} updated, {} total rewrite(s).",
            total_files, files_changed, total_rewrites
        );
    }
    Ok(ExitCode::SUCCESS)
}

fn list_toml_files(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    let read =
        std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
    for entry in read {
        let entry = entry.with_context(|| format!("reading entry under {}", dir.display()))?;
        let path = entry.path();
        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("toml") {
            out.push(path);
        }
    }
    out.sort();
    Ok(out)
}

/// Atomic file replace: write the new content into a tempfile in the same
/// directory, fsync, then rename onto the target. A crash mid-write
/// leaves the original file intact rather than truncating it.
fn atomic_write(path: &Path, content: &str) -> Result<()> {
    let parent = path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let tmp = tempfile::NamedTempFile::new_in(parent)
        .with_context(|| format!("creating tempfile in {}", parent.display()))?;
    {
        use std::io::Write;
        let mut handle = tmp.as_file();
        handle.write_all(content.as_bytes())?;
        handle.flush()?;
        handle.sync_all()?;
    }
    tmp.persist(path).map_err(|e| e.error)?;
    Ok(())
}

fn print_detector_verbose(d: &DetectorSpec) {
    println!();
    println!("  {}", d.id);
    println!("    name:      {}", d.name);
    println!("    service:   {}", d.service);
    println!("    severity:  {:?}", d.severity);
    if !d.keywords.is_empty() {
        println!("    keywords:  {}", d.keywords.join(", "));
    }
    for (i, p) in d.patterns.iter().enumerate() {
        let label = if d.patterns.len() > 1 {
            format!("pattern[{i}]")
        } else {
            "pattern".to_string()
        };
        println!("    {label}:   {}", p.regex);
        if let Some(desc) = &p.description {
            println!("      desc:    {desc}");
        }
        if let Some(g) = p.group {
            println!("      group:   {g}");
        }
    }
    if !d.companions.is_empty() {
        println!("    companions:");
        for c in &d.companions {
            println!(
                "      - {} (within {} lines, required={}): {}",
                c.name, c.within_lines, c.required, c.regex
            );
        }
    }
    if d.verify.is_some() {
        println!("    verify:    yes");
    }
}

#[doc(hidden)]
pub fn rewrite_braces_for_test(s: &str) -> (String, usize) {
    rewrite_braces(s)
}

#[doc(hidden)]
pub fn fix_single_brace_in_verify_blocks_for_test(toml_text: &str) -> (String, usize) {
    fix_single_brace_in_verify_blocks(toml_text)
}

#[doc(hidden)]
pub fn rewrite_braces_in_string_literals_for_test(line: &str) -> (String, usize) {
    rewrite_braces_in_string_literals(line)
}