keyhog-scanner 0.5.73

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

#[derive(Debug)]
pub(crate) struct DetectorSuppressionPolicy {
    allowlist_paths: Vec<regex::Regex>,
    source_path_patterns: Vec<regex::Regex>,
    source_types: Vec<String>,
    file_extensions: Vec<String>,
    allowlist_values: Vec<regex::Regex>,
    stopwords: Vec<String>,
}

impl DetectorSuppressionPolicy {
    pub(crate) fn compile(spec: &keyhog_core::DetectorSpec) -> Result<Option<Self>, String> {
        Self::hydrate_parts(
            &spec.id,
            &spec.allowlist_paths,
            &spec.allowlist_values,
            &spec.stopwords,
            &spec.source_admission,
        )
    }

    pub(crate) fn hydrate(
        spec: &crate::execution_pack::detector_plan::DetectorPlanRecord,
    ) -> Result<Option<Self>, String> {
        Self::hydrate_parts(
            &spec.id,
            &spec.allowlist_paths,
            &spec.allowlist_values,
            &spec.stopwords,
            &spec.source_admission,
        )
    }

    fn hydrate_parts(
        detector_id: &str,
        allowlist_paths: &[String],
        allowlist_values: &[String],
        stopwords: &[String],
        source_admission: &keyhog_core::SourceAdmissionSpec,
    ) -> Result<Option<Self>, String> {
        if allowlist_paths.is_empty()
            && allowlist_values.is_empty()
            && stopwords.is_empty()
            && source_admission.path_patterns.is_empty()
            && source_admission.source_types.is_empty()
            && source_admission.file_extensions.is_empty()
        {
            return Ok(None);
        }
        let compile = |field: &str, patterns: &[String]| {
            patterns
                .iter()
                .map(|pattern| {
                    regex::Regex::new(pattern).map_err(|error| {
                        format!(
                            "detector {detector_id:?} {field} regex {pattern:?} failed to compile: {error}"
                        )
                    })
                })
                .collect::<Result<Vec<_>, _>>()
        };
        Ok(Some(Self {
            allowlist_paths: compile("allowlist_paths", allowlist_paths)?,
            source_path_patterns: compile(
                "source_admission.path_patterns",
                &source_admission.path_patterns,
            )?,
            source_types: source_admission.source_types.clone(),
            file_extensions: source_admission.file_extensions.clone(),
            allowlist_values: compile("allowlist_values", allowlist_values)?,
            stopwords: stopwords.to_vec(),
        }))
    }

    pub(crate) fn allowlist_stage(
        &self,
        path: Option<&str>,
        source_family: Option<&str>,
        credential: &str,
    ) -> Option<crate::adjudicate::StageId> {
        if let Some(reason) = self.source_admission_rejection(path, source_family) {
            crate::adjudicate::record_example_suppression("pipeline", path, credential, reason);
            return Some(crate::adjudicate::StageId::ShapeGate(reason));
        }
        if let Some(path) = path {
            if self
                .allowlist_paths
                .iter()
                .any(|regex| regex.is_match(path))
            {
                crate::adjudicate::record_example_suppression(
                    "pipeline",
                    Some(path),
                    credential,
                    "allowlist_paths",
                );
                return Some(crate::adjudicate::StageId::ShapeGate("allowlist_paths"));
            }
        }
        if self
            .allowlist_values
            .iter()
            .any(|regex| regex.is_match(credential))
        {
            crate::adjudicate::record_example_suppression(
                "pipeline",
                path,
                credential,
                "allowlist_values",
            );
            return Some(crate::adjudicate::StageId::ShapeGate("allowlist_values"));
        }
        None
    }

    pub(crate) fn stopword_stage(
        &self,
        path: Option<&str>,
        credential: &str,
        randomness: &TokenRandomness<'_>,
    ) -> Option<crate::adjudicate::StageId> {
        if self.stopwords.is_empty() || randomness.is_random_token(credential) {
            return None;
        }
        if self
            .stopwords
            .iter()
            .any(|word| keyhog_core::contains_ignore_ascii_case(credential, word))
        {
            crate::adjudicate::record_example_suppression(
                "pipeline",
                path,
                credential,
                "stopwords",
            );
            return Some(crate::adjudicate::StageId::ShapeGate("stopwords"));
        }
        None
    }

    pub(crate) fn full_stage(
        &self,
        path: Option<&str>,
        source_family: Option<&str>,
        credential: &str,
    ) -> Option<crate::adjudicate::StageId> {
        self.allowlist_stage(path, source_family, credential)
            .or_else(|| {
                let randomness = TokenRandomness::for_candidate(credential);
                self.stopword_stage(path, credential, &randomness)
            })
    }

    fn source_admission_rejection(
        &self,
        path: Option<&str>,
        source_family: Option<&str>,
    ) -> Option<&'static str> {
        if !self.source_path_patterns.is_empty()
            && !path.is_some_and(|path| {
                self.source_path_patterns
                    .iter()
                    .any(|pattern| pattern.is_match(path))
            })
        {
            return Some("source_admission_path");
        }
        if !self.source_types.is_empty()
            && !source_family.is_some_and(|source_family| {
                self.source_types
                    .iter()
                    .any(|admitted| admitted == source_family)
            })
        {
            return Some("source_admission_type");
        }
        if !self.file_extensions.is_empty()
            && !path
                .and_then(|path| path.rsplit('/').next())
                .and_then(|name| name.rsplit_once('.').map(|(_, extension)| extension))
                .is_some_and(|extension| {
                    self.file_extensions
                        .iter()
                        .any(|admitted| extension.eq_ignore_ascii_case(admitted))
                })
        {
            return Some("source_admission_extension");
        }
        None
    }

    #[cfg(test)]
    pub(crate) fn test_fixture() -> Self {
        Self {
            allowlist_paths: vec![regex::Regex::new(".*allowlisted_path.*").unwrap()],
            allowlist_values: vec![regex::Regex::new("^allowlisted_value_.*").unwrap()],
            stopwords: vec!["stopword_here".to_string()],
            source_path_patterns: Vec::new(),
            source_types: Vec::new(),
            file_extensions: Vec::new(),
        }
    }
}

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