keyhog-scanner 0.5.73

keyhog-scanner: high-performance SIMD-accelerated secret detection engine
use super::looks_like_public_version_identifier_with_randomness;
use crate::suppression::token_randomness::TokenRandomness;

/// Source-code expressions are not credentials. This catches extracted
/// function/method/type syntax such as `TokenizationScratch::default()`,
/// `vec![...]`, `.unwrap_or(u32::MAX)`, and `foo(bar)` after the entropy or
/// generic bridge has already mistaken nearby `token`/`key` identifiers for a
/// credential anchor.
pub(crate) fn looks_like_source_code_expression_with_randomness(
    value: &str,
    randomness: &TokenRandomness<'_>,
) -> bool {
    let bytes = value.as_bytes();
    if bytes.len() < 6 || bytes.len() > 240 {
        return false;
    }
    if looks_like_source_numeric_literal(value)
        || looks_like_source_const_label(value)
        || looks_like_source_string_terminator_fragment(value, randomness)
        || looks_like_source_escaped_string_fragment(value, randomness)
        || looks_like_source_format_template_fragment(value)
        || looks_like_template_interpolation_prefix(value)
        || looks_like_javascript_length_index_tail(value)
    {
        return true;
    }

    let mut alpha = 0usize;
    for &byte in bytes {
        match byte {
            b'a'..=b'z' | b'A'..=b'Z' => alpha += 1,
            b'0'..=b'9'
            | b'_'
            | b':'
            | b'.'
            | b'!'
            | b'('
            | b')'
            | b'['
            | b']'
            | b'{'
            | b'}'
            | b'<'
            | b'>'
            | b','
            | b';'
            | b'&'
            | b'|'
            | b'+'
            | b'-'
            | b'*'
            | b'/'
            | b'%'
            | b'='
            | b'?'
            | b'"'
            | b'\''
            | b'`'
            | b'#'
            | b' '
            | b'\t' => {}
            _ => return false,
        }
    }
    if alpha < 3 {
        return false;
    }

    value.contains("::")
        || value.starts_with('.')
        || value.starts_with(':')
        || value.contains("->")
        || value.contains("=>")
        || has_call_or_index_syntax(value)
}

fn looks_like_javascript_length_index_tail(value: &str) -> bool {
    let receiver = [".length]))", ".length])"]
        .iter()
        .find_map(|suffix| value.strip_suffix(suffix));
    let Some(receiver) = receiver else {
        return false;
    };
    let mut bytes = receiver.bytes();
    let Some(first) = bytes.next() else {
        return false;
    };
    (first.is_ascii_alphabetic() || matches!(first, b'_' | b'$'))
        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$'))
}

fn looks_like_template_interpolation_prefix(value: &str) -> bool {
    let Some(identifier) = value.strip_suffix('$') else {
        return false;
    };
    identifier.starts_with("__")
        && identifier.len() >= 4
        && identifier.bytes().any(|byte| byte.is_ascii_alphabetic())
        && identifier
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}

fn looks_like_source_numeric_literal(value: &str) -> bool {
    let Some(rest) = value.strip_prefix("0x") else {
        return false;
    };
    if rest.len() < 6 || rest.len() > 40 || !rest.contains('_') {
        return false;
    }
    let rust_suffix = rest.rsplit_once('_').and_then(|(digits, suffix)| {
        matches!(
            suffix,
            "u8" | "u16"
                | "u32"
                | "u64"
                | "u128"
                | "usize"
                | "i8"
                | "i16"
                | "i32"
                | "i64"
                | "i128"
                | "isize"
        )
        .then_some(digits)
    });
    let (digit_body, suffix_ok) = rust_suffix.map_or((rest, false), |digits| (digits, true));
    let mut hex_digits = 0usize;
    let mut groups = 0usize;
    for part in digit_body.split('_') {
        if part.is_empty() || part.len() > 8 || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
            return false;
        }
        hex_digits += part.len();
        groups += 1;
    }
    hex_digits >= 6 && (suffix_ok || groups >= 2)
}

fn looks_like_source_const_label(value: &str) -> bool {
    let Some(rest) = value.strip_prefix("const:") else {
        return false;
    };
    rest.len() >= 3
        && rest.len() <= 80
        && rest.bytes().any(|b| b == b'_')
        && rest
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}

fn looks_like_source_string_terminator_fragment(
    value: &str,
    randomness: &TokenRandomness<'_>,
) -> bool {
    let Some(prefix) = value.strip_suffix("\")") else {
        return false;
    };
    if prefix.len() < 2 || prefix.len() > 80 {
        return false;
    }
    looks_like_public_version_identifier_with_randomness(prefix, randomness)
        || looks_like_short_version_literal(prefix)
        || looks_like_source_symbol_identifier_with_randomness(prefix, randomness)
}

fn looks_like_short_version_literal(value: &str) -> bool {
    let Some((version, suffix)) = value.split_once(':') else {
        return false;
    };
    let Some(digits) = version.strip_prefix('v') else {
        return false;
    };
    !digits.is_empty()
        && digits.len() <= 3
        && digits.bytes().all(|b| b.is_ascii_digit())
        && !suffix.is_empty()
        && suffix.len() <= 24
        && suffix
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}

fn looks_like_source_escaped_string_fragment(
    value: &str,
    randomness: &TokenRandomness<'_>,
) -> bool {
    let Some(after_quote) = value.strip_prefix("\\\"") else {
        return false;
    };
    let inner = after_quote.trim_end_matches('\\');
    if inner.len() < 6 || inner.len() > 40 {
        return false;
    }
    !randomness.is_random_token(inner)
        && inner
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}

fn looks_like_source_format_template_fragment(value: &str) -> bool {
    let Some((name, template)) = value.split_once('=') else {
        return false;
    };
    if name.len() < 3
        || name.len() > 48
        || !name
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
        || !name.bytes().any(|b| b.is_ascii_alphabetic())
    {
        return false;
    }
    template.contains('%')
        && (template.contains("\\n")
            || template.contains("%X")
            || template.contains("%x")
            || template.contains("%d")
            || template.contains("%s"))
}

crate::tier_b_list::tier_b_vec!(SOURCE_TYPE_TERMS, "source-type-terms.toml", terms);

crate::tier_b_list::tier_b_vec!(SOURCE_RECEIVERS, "source-receivers.toml", receivers);

pub(crate) fn looks_like_source_symbol_identifier_with_randomness(
    value: &str,
    randomness: &TokenRandomness<'_>,
) -> bool {
    let bytes = value.as_bytes();
    if bytes.len() < 8 || bytes.len() > 96 {
        return false;
    }
    if !bytes[0].is_ascii_alphabetic() && bytes[0] != b'_' {
        return false;
    }
    if !bytes
        .iter()
        .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
    {
        return false;
    }
    if value.contains('_') {
        return value
            .split('_')
            .all(|part| !part.is_empty() && part.len() <= 24);
    }
    let upper = bytes.iter().filter(|b| b.is_ascii_uppercase()).count();
    let lower = bytes.iter().filter(|b| b.is_ascii_lowercase()).count();
    if upper < 2 || lower < 3 {
        return false;
    }
    SOURCE_TYPE_TERMS
        .iter()
        .any(|term| value.contains(term.as_str()))
        || !randomness.is_random_token(value)
}

/// Heuristic: is this string a likely source-code identifier rather than a
/// credential? Identifiers in mainstream languages are all `[A-Za-z_]` with
/// camelCase / PascalCase / snake_case shape.
pub(crate) fn looks_like_program_identifier(value: &str) -> bool {
    if !value
        .chars()
        .all(|ch| ch.is_ascii_alphabetic() || ch == '_')
    {
        return false;
    }
    if value.contains('_') && value.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_') {
        return true;
    }
    let bytes = value.as_bytes();
    let mut transitions = 0usize;
    for pair in bytes.windows(2) {
        if pair[0].is_ascii_lowercase() && pair[1].is_ascii_uppercase() {
            transitions += 1;
        }
    }
    transitions >= 1
}

pub(crate) fn looks_like_kebab_config_identifier(value: &str) -> bool {
    if value.len() > 24 {
        return false;
    }
    let bytes = value.as_bytes();
    let dash_count = bytes.iter().filter(|&&b| b == b'-').count();
    if dash_count == 0 {
        return false;
    }
    let lower_count = bytes
        .iter()
        .filter(|&&b| (b as char).is_ascii_lowercase())
        .count();
    if lower_count * 2 < bytes.len() {
        return false;
    }
    !bytes.iter().any(|&b| matches!(b as char, '+' | '/' | '='))
}

pub(crate) fn looks_like_dotted_source_identifier(value: &str) -> bool {
    // Single validating pass over `.`-separated segments (Law 7: per-candidate
    // suppression predicate, no `Vec`). Tracks count, first segment (for the
    // receiver match), camel-case presence, and credential-word presence.
    let mut count = 0usize;
    let mut first = "";
    let mut has_camel_segment = false;
    let mut has_credential_word = false;
    for segment in value.split('.') {
        count += 1;
        if count == 1 {
            first = segment;
        }
        let seg = segment.as_bytes();
        if seg.is_empty()
            || !seg
                .iter()
                .all(|&byte| byte.is_ascii_alphanumeric() || byte == b'_')
        {
            return false;
        }
        if seg
            .windows(2)
            .any(|pair| pair[0].is_ascii_lowercase() && pair[1].is_ascii_uppercase())
        {
            has_camel_segment = true;
        }
        // `ci_find` needles are pre-lowered against the ONE canonical
        // credential-keyword needle set (`super::CREDENTIAL_KEYWORD_NEEDLES`).
        if super::CREDENTIAL_KEYWORD_NEEDLES
            .iter()
            .any(|needle| crate::ascii_ci::ci_find(seg, needle.as_slice()))
        {
            has_credential_word = true;
        }
    }
    if !(2..=5).contains(&count) {
        return false;
    }

    // Receiver match without allocating a lowercased copy of the first segment.
    if SOURCE_RECEIVERS
        .iter()
        .any(|recv| first.eq_ignore_ascii_case(recv.as_str()))
    {
        return true;
    }

    has_camel_segment && has_credential_word
}

fn has_call_or_index_syntax(value: &str) -> bool {
    let bytes = value.as_bytes();
    for (idx, &byte) in bytes.iter().enumerate() {
        if !matches!(byte, b'(' | b'[' | b'{') {
            continue;
        }
        if idx == 0 {
            return true;
        }
        let Some(prev) = bytes[..idx]
            .iter()
            .rev()
            .copied()
            .find(|b| !matches!(b, b' ' | b'\t'))
        else {
            return true;
        };
        if prev.is_ascii_alphanumeric()
            || matches!(
                prev,
                b'_' | b'!' | b')' | b']' | b'>' | b'+' | b'-' | b'*' | b'/' | b'%'
            )
        {
            return true;
        }
    }
    false
}

#[cfg(feature = "entropy")]
pub(crate) fn looks_like_source_type_identifier_with_randomness(
    value: &str,
    randomness: &TokenRandomness<'_>,
) -> bool {
    let bytes = value.as_bytes();
    if bytes.len() < 8 || bytes.len() > 120 || !bytes[0].is_ascii_uppercase() {
        return false;
    }
    if !keyhog_core::ascii_ci::is_ascii_alphanumeric_bytes(bytes) {
        return false;
    }
    let upper = bytes.iter().filter(|b| b.is_ascii_uppercase()).count();
    let lower = bytes.iter().filter(|b| b.is_ascii_lowercase()).count();
    let digits = bytes.iter().filter(|b| b.is_ascii_digit()).count();
    upper >= 2 && lower >= 3 && digits >= 1 && !randomness.is_random_token(value)
}

#[cfg(test)]
#[path = "../../../tests/unit/suppression_shape_source.rs"]
mod tests;