keyhog 0.5.38

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
//! Inline suppression handling for CLI findings.

use keyhog_core::RawMatch;
use std::collections::HashMap;

const INLINE_SUPPRESSION_DIRECTIVES: &[&str] = &[
    "keyhog:ignore",
    "keyhog:allow",
    "gitleaks:allow",
    "betterleaks:allow",
];
const DETECTOR_DIRECTIVE_PREFIX: &str = "detector=";
const INLINE_COMMENT_MARKERS: &[&str] = &["//", "#", "--", "/*", "<!--"];

pub fn filter_inline_suppressions(matches: Vec<RawMatch>) -> Vec<RawMatch> {
    use std::io::BufRead;

    let mut files_to_matches: HashMap<String, Vec<RawMatch>> = HashMap::new();
    let mut non_file_matches = Vec::new();

    for m in matches {
        if m.location.source.as_ref() == "filesystem" {
            if let Some(path) = m.location.file_path.clone() {
                files_to_matches
                    .entry(path.to_string())
                    .or_default()
                    .push(m);
                continue;
            }
        }
        non_file_matches.push(m);
    }

    let mut filtered_matches = non_file_matches;
    for (path, mut file_matches) in files_to_matches {
        file_matches.sort_by_key(|m| m.location.line.unwrap_or(0));

        if let Ok(file) = std::fs::File::open(&path) {
            let mut reader = std::io::BufReader::new(file);
            let mut line_buf = String::new();
            let mut current_line_num = 1;
            let mut prev_line = String::new();
            let mut current_line = String::new();

            for m in file_matches {
                let Some(target_line) = m.location.line else {
                    filtered_matches.push(m);
                    continue;
                };

                while current_line_num <= target_line {
                    line_buf.clear();
                    if reader.read_line(&mut line_buf).unwrap_or(0) > 0 {
                        // Trim trailing newline for the directive check
                        let line = line_buf.trim_end_matches(['\n', '\r']).to_string();
                        prev_line = std::mem::replace(&mut current_line, line);
                        current_line_num += 1;
                    } else {
                        break;
                    }
                }

                if !is_inline_suppressed_buffered(&prev_line, &current_line, &m.detector_id) {
                    filtered_matches.push(m);
                }
            }
        } else {
            filtered_matches.extend(file_matches);
        }
    }

    filtered_matches
}

fn is_inline_suppressed_buffered(prev_line: &str, current_line: &str, detector_id: &str) -> bool {
    line_has_inline_suppression(prev_line, detector_id)
        || line_has_inline_suppression(current_line, detector_id)
}

fn line_has_inline_suppression(line: &str, detector_id: &str) -> bool {
    let Some(directive) = inline_suppression_directive(line) else {
        return false;
    };
    // `directive` is already lowercased by `inline_suppression_directive`
    // (it operates on a lowercased copy of the line). Compare the
    // `detector=` token case-insensitively against `detector_id` rather
    // than allocating a lowercased copy of the id on every match - this
    // runs once per finding in the suppression hot path.
    match directive
        .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';'))
        .find_map(|token| token.strip_prefix(DETECTOR_DIRECTIVE_PREFIX))
    {
        Some(expected) => expected.eq_ignore_ascii_case(detector_id),
        None => true,
    }
}

fn inline_suppression_directive(line: &str) -> Option<String> {
    let lower = line.to_ascii_lowercase();
    let directive = comment_segments(&lower).find_map(extract_directive_from_comment);
    directive
}

fn comment_segments(line: &str) -> impl Iterator<Item = &str> {
    INLINE_COMMENT_MARKERS
        .iter()
        .filter_map(|marker| line.find(marker).map(|index| &line[index + marker.len()..]))
}

fn extract_directive_from_comment(comment: &str) -> Option<String> {
    for &dir in INLINE_SUPPRESSION_DIRECTIVES {
        if let Some(directive_index) = comment.find(dir) {
            if comment[..directive_index]
                .chars()
                .any(|character| !character.is_whitespace())
            {
                continue;
            }
            let directive = &comment[directive_index..];
            return Some(directive.to_string());
        }
    }
    None
}