keyhog-scanner 0.5.73

keyhog-scanner: high-performance SIMD-accelerated secret detection engine
//! SIMD-accelerated prefilter for the top N most common secret patterns.
//!
//! `simdsieve` checks keyhog's hot prefixes in a single AVX-512/AVX2/NEON
//! pass. (The crate's 50+ GB/s headline is its single-byte-prefix peak;
//! multi-byte prefixes like these run lower, throughput scales down with
//! prefix length, but still far faster than running AC/regex on every byte.)
//! This module integrates it as Layer 1 of the scanning pipeline:
//! hot patterns are checked first, and if found, we can often skip AC/Regex.

#[inline]
pub(crate) fn hot_pattern_index_at(
    slots: &[HotPatternSlot],
    text_bytes: &[u8],
    offset: usize,
) -> Option<usize> {
    let rest = text_bytes.get(offset..)?;
    slots
        .iter()
        .enumerate()
        .find_map(|(idx, slot)| rest.starts_with(&slot.prefix).then_some(idx))
}

/// Everything the SIMD hot fast-path needs to turn a literal-prefix sieve hit
/// at slot `i` into a precise finding, kept in ONE row so a slot's validator and
/// its `ac_map` delegate are physically inseparable, they can never be indexed
/// apart and so can never drift. Slot order follows loaded detector specs and
/// their `simdsieve_prefixes`; built once by
/// `compiled_scanner::compile_helpers::build_hot_pattern_slots`.
///
/// Before unification these were two separate `Vec`s on `CompiledScanner`
/// (`hot_pattern_validators` and `hot_ac_map_index_by_index`) read by the SAME
/// `pattern_idx` at scan time. Nothing structurally bound slot `i`'s validator
/// to slot `i`'s `ac_map` entry, only construction discipline and a runtime
/// length-equality guard. A future edit that filtered one vec but not the other
/// would have silently applied slot `i`'s validator to slot `j`'s detector: a
/// wrong-detector emission, invisible. One row per slot makes that
/// unrepresentable.
#[derive(Debug)]
pub(crate) struct HotPatternSlot {
    pub(crate) prefix: Box<[u8]>,
    /// Precise-regex validator (anchored at the candidate start) every
    /// literal-prefix candidate for this slot must satisfy before emission
    /// restores AC+regex parity so the fast path can't surface a token the
    /// detector's own regex rejects (the length floor alone let
    /// `ghp_…_…`/`xoxp-123-456-789-abc` through).
    /// Authenticated packed scans materialize it only on the first prefix hit.
    pub(crate) validator: crate::types::LazyRegex,
    /// Canonical confirmed-pattern `ac_map` entry this slot accelerates.
    /// The SIMD hit is only an accelerator for `ac_map[i]` and delegates
    /// surviving candidates through `process_match`.
    pub(crate) ac_map_index: usize,
}

/// Build the precise-regex validator for a detector-owned hot-pattern slot.
///
/// The hot path is a literal-prefix prefilter: a single-pass SIMD sieve finds
/// `ghp_`/`xoxp-`/`AKIA`/… and historically emitted a `Critical` finding
/// gated ONLY by a per-prefix length floor. A length floor is a crude proxy for the
/// detector's real regex and admits wrong-character-class tokens the precise
/// pattern rejects:
///   - `ghp_THIS_HAS_UNDERSCORES_IN_IT_NOT_A_TOKEN0` (43 ≥ 40 floor, but `_`
///     is not in `[A-Za-z0-9]` and the body is 39 chars, not 36), and
///   - `xoxp-123-456-789-abc` (20 ≥ 16 floor, but the segments are far short
///     of the 10-13-digit Slack shape)
/// both cleared the floor and surfaced as `Critical` false positives that the
/// AC+regex path correctly rejected. Validating each candidate against the
/// detector's own regex (anchored at the candidate start) restores parity: the
/// fast path emits exactly what the precise path would, just sooner.
///
/// Slots exist only for prefixes declared by loaded detectors. A detector that
/// is not loaded therefore cannot create a hot-path finding.
///
/// This module (`mod simdsieve_prefilter`) and the sole caller in
/// `compiled_scanner::compile` are both gated on `feature = "simdsieve"`, so whenever
/// this function is compiled its caller is too: no `#[allow(dead_code)]` is
/// needed.
pub(crate) fn build_hot_pattern_validator(
    detector: &keyhog_core::DetectorSpec,
) -> crate::error::Result<regex::Regex> {
    let source = hot_pattern_validator_source_parts(&detector.id, &detector.patterns)?;
    compile_hot_pattern_validator(&detector.id, &source)
}

pub(crate) fn build_hot_pattern_slot_validator(
    detector: &keyhog_core::DetectorSpec,
) -> crate::error::Result<crate::types::LazyRegex> {
    let source = hot_pattern_validator_source_parts(&detector.id, &detector.patterns)?;
    drop(compile_hot_pattern_validator(&detector.id, &source)?);
    Ok(crate::types::LazyRegex::detector(source))
}

pub(crate) fn hydrate_hot_pattern_validator(
    detector: &crate::execution_pack::detector_plan::DetectorPlanRecord,
) -> crate::error::Result<crate::types::LazyRegex> {
    let source = hot_pattern_validator_source_parts(&detector.id, &detector.patterns)?;
    Ok(crate::types::LazyRegex::detector(source))
}

fn hot_pattern_validator_source_parts(
    detector_id: &str,
    patterns: &[keyhog_core::PatternSpec],
) -> crate::error::Result<String> {
    if patterns.is_empty() {
        return Err(crate::error::ScanError::Config(format!(
            "detector {} declares simdsieve prefixes but has no regex patterns",
            detector_id
        )));
    }
    let source_bytes = patterns
        .iter()
        .map(|pattern| pattern.regex.len().saturating_add(4))
        .sum::<usize>();
    let mut combined = String::with_capacity(source_bytes.saturating_add(5));
    combined.push_str("^(?:");
    for (index, pattern) in patterns.iter().enumerate() {
        if index != 0 {
            combined.push('|');
        }
        combined.push_str("(?:");
        combined.push_str(&pattern.regex);
        combined.push(')');
    }
    combined.push(')');
    Ok(combined)
}

fn compile_hot_pattern_validator(
    detector_id: &str,
    source: &str,
) -> crate::error::Result<regex::Regex> {
    // Law 10: fail closed on a build error. Embedded/custom scanners validate
    // before retaining the lazy source; authenticated packs carry that proof
    // and materialize the same source only when its literal prefix occurs.
    regex::RegexBuilder::new(source)
        .case_insensitive(true)
        .size_limit(crate::types::REGEX_SIZE_LIMIT_BYTES)
        .dfa_size_limit(crate::types::regex_dfa_limit())
        .crlf(true)
        .build()
        .map_err(|source| crate::error::ScanError::RegexCompile {
            detector_id: detector_id.to_owned(),
            index: 0,
            source,
        })
}

/// Build validators for ALL detectors that declare simdsieve prefixes,
/// returning one `Option<Regex>` per detector (in the same order as the
/// input slice). Detectors without simdsieve prefixes get `None`.
#[cfg(feature = "simdsieve")]
pub(crate) fn build_hot_pattern_validators(
    detectors: &[keyhog_core::DetectorSpec],
) -> crate::error::Result<Vec<Option<regex::Regex>>> {
    detectors
        .iter()
        .map(|detector| {
            if detector.simdsieve_prefixes.is_empty() {
                Ok(None)
            } else {
                build_hot_pattern_validator(detector).map(Some)
            }
        })
        .collect()
}

/// Static hot-pattern data: (prefix bytes, detector id) pairs, one per
/// simdsieve prefix across all embedded detectors. Computed once from
/// `keyhog_core::embedded_detector_specs()` and leaked to satisfy the
/// `&'static` contract used by test helpers.
#[cfg(feature = "simdsieve")]
static HOT_PATTERN_DATA: std::sync::OnceLock<(&'static [&'static [u8]], &'static [&'static str])> =
    std::sync::OnceLock::new();

#[cfg(feature = "simdsieve")]
fn compute_hot_pattern_data() -> (&'static [&'static [u8]], &'static [&'static str]) {
    let detectors = keyhog_core::embedded_detector_specs();
    let mut prefixes: Vec<&'static [u8]> = Vec::new();
    let mut detector_ids: Vec<&'static str> = Vec::new();
    for detector in detectors {
        for prefix in &detector.simdsieve_prefixes {
            // Leak the prefix string to get a 'static slice. This runs once
            // per process and the total volume is tiny (< 16 prefixes).
            let static_prefix: &'static [u8] =
                Box::leak(prefix.clone().into_bytes().into_boxed_slice());
            prefixes.push(static_prefix);
            detector_ids.push(detector.id.as_str());
        }
    }
    // Leak the Vecs into 'static slices. One-time cost, tiny volume.
    let static_prefixes: &'static [&'static [u8]] = Box::leak(prefixes.into_boxed_slice());
    let static_ids: &'static [&'static str] = Box::leak(detector_ids.into_boxed_slice());
    (static_prefixes, static_ids)
}

/// The canonical hot-pattern prefix bytes, one entry per simdsieve prefix
/// across all embedded detectors. Order follows `embedded_detector_specs()`.
#[cfg(feature = "simdsieve")]
pub(crate) static HOT_PATTERNS: std::sync::LazyLock<&'static [&'static [u8]]> =
    std::sync::LazyLock::new(|| HOT_PATTERN_DATA.get_or_init(compute_hot_pattern_data).0);

/// The canonical hot-pattern detector IDs, one per simdsieve prefix (parallel
/// to [`HOT_PATTERNS`]). Each prefix is paired with the detector that owns it.
#[cfg(feature = "simdsieve")]
pub(crate) static HOT_PATTERN_DETECTOR_IDS: std::sync::LazyLock<&'static [&'static str]> =
    std::sync::LazyLock::new(|| HOT_PATTERN_DATA.get_or_init(compute_hot_pattern_data).1);