keyhog-scanner 0.5.73

keyhog-scanner: high-performance SIMD-accelerated secret detection engine
//! Detector-owned credential shape rules.
//!
//! The per-detector shape CONSTRAINT (`exact_length` / `prefix` / `body_*`) is a
//! `keyhog_core::CredentialShape` declared in each detector's own TOML
//! (`[detector.credential_shape]`, DET-0, was the centralized
//! `rules/detector-credential-shapes.toml` `[[shape]]` list keyed by detector id).
//! Core owns the data AND its internal-consistency validation
//! (`CredentialShape::validate`); this module owns the SCANNER side: the compiled
//! [`CredentialShapeRule`] + its per-credential [`CredentialShapeRule::allows`]
//! gate, built per detector from that spec. Because the shape now lives on the
//! detector's own spec, the previous "shape rule for an unknown detector id" class
//! is impossible by construction (no id list, no id validation).

use keyhog_core::{CredentialShape, DetectorSpec};

/// The PEM armor header that opens every `-----BEGIN … PRIVATE KEY-----` block
/// (and X.509 certs). SINGLE OWNER: it is the load-bearing prefix of the
/// `private-key` / `ssh-private-key` / `github-app-private-key` detector
/// patterns, and scanner logic keys off it in two places, the suppression
/// carve-out (a PEM body must NOT be masking-pattern suppressed, or the detector
/// silently misses real OPENSSH keys) and the entropy plausibility gate. Both
/// now read this const via [`is_pem_block`] instead of two bare `"-----BEGIN"`
/// literals free to drift; a guard test binds it to its authoritative detector.
pub(crate) const PEM_BEGIN_MARKER: &str = "-----BEGIN";

/// True when `value` opens a PEM armor block (private key, certificate, …).
/// One predicate so every "is this a PEM body?" decision agrees byte-for-byte.
pub(crate) fn is_pem_block(value: &str) -> bool {
    value.starts_with(PEM_BEGIN_MARKER)
}

/// The password component of a `scheme://user:password@host` URL, or `None`
/// when `value` carries no such credentialled-URL prefix.
///
/// SINGLE OWNER of "where does the secret live inside a connection string?".
/// Two callers need the same answer and previously could not agree, because
/// only one of them existed: the Caesar decoder asks the yes/no form ("is this
/// line already a plaintext credential URL, so ROT-N can reveal nothing?") and
/// the suppression tree asks for the span itself, because a connection-string
/// detector captures the WHOLE url as the credential, so a placeholder password
/// (`postgresql://user:<password>@host`) is a sub-field, invisible to every
/// whole-value placeholder gate.
///
/// Shape: `<scheme>://` where the scheme is 2+ alphabetic (or `+`) bytes, then
/// userinfo up to the first `/`, `?`, `#`, or whitespace. The first `@` in that
/// span ends the userinfo and the first `:` before it ends the username; the
/// bytes between them are the password. A URL with no `:` before the `@`
/// (`https://host`, `ssh://git@host`) carries no password and yields `None`.
/// An empty password (`postgres://user:@host`) yields `Some("")`: the field is
/// present and empty, which is not the same as absent.
pub(crate) fn credential_url_userinfo_password(value: &str) -> Option<&str> {
    let scheme_end = value.find("://")?;
    let scheme_tail = value.as_bytes()[..scheme_end]
        .iter()
        .rev()
        .take_while(|byte| byte.is_ascii_alphabetic() || **byte == b'+')
        .count();
    if scheme_tail < 2 {
        return None;
    }
    let rest = &value[scheme_end + 3..];
    // LAW10: a userinfo run with no terminator is the whole remainder; the `@`
    // search below still decides whether a credential is present.
    let userinfo_end = rest
        .find(|c: char| c == '/' || c == '?' || c == '#' || c.is_ascii_whitespace())
        .unwrap_or(rest.len());
    let userinfo = &rest[..userinfo_end];
    let at = userinfo.find('@')?;
    let colon = userinfo[..at].find(':')?;
    Some(&userinfo[colon + 1..at])
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CredentialShapeRule {
    exact_length: Option<usize>,
    prefix: Option<String>,
    body_min_length: Option<usize>,
    body_max_length: Option<usize>,
}

impl CredentialShapeRule {
    pub(crate) fn allows(&self, credential: &str) -> bool {
        if self
            .exact_length
            .is_some_and(|expected| credential.len() != expected)
        {
            return false;
        }

        if let Some(prefix) = self.prefix.as_deref() {
            let Some(body) = credential.strip_prefix(prefix) else {
                return true;
            };
            if self
                .body_min_length
                .is_some_and(|minimum| body.len() < minimum)
            {
                return false;
            }
            if self
                .body_max_length
                .is_some_and(|maximum| body.len() > maximum)
            {
                return false;
            }
        }

        true
    }

    /// Compile the scanner-side gate from a detector's own declared shape
    /// (`DetectorSpec::credential_shape`). The spec is validated separately at
    /// build time (`CredentialShape::validate`); this only maps the fields.
    fn from_spec(shape: &CredentialShape) -> Self {
        Self {
            exact_length: shape.exact_length,
            prefix: shape.prefix.clone(),
            body_min_length: shape.body_min_length,
            body_max_length: shape.body_max_length,
        }
    }

    #[cfg(test)]
    pub(crate) fn exact_length_for_test(exact_length: usize) -> Self {
        Self {
            exact_length: Some(exact_length),
            prefix: None,
            body_min_length: None,
            body_max_length: None,
        }
    }

    #[cfg(test)]
    pub(crate) fn prefix_body_range_for_test(
        prefix: &str,
        body_min_length: usize,
        body_max_length: usize,
    ) -> Self {
        Self {
            exact_length: None,
            prefix: Some(prefix.to_string()),
            body_min_length: Some(body_min_length),
            body_max_length: Some(body_max_length),
        }
    }
}

/// Compile one detector's credential-shape gate for its runtime plan. The
/// shape comes from its OWN spec
/// (`DetectorSpec::credential_shape`, DET-0), validated fail-closed
/// (`CredentialShape::validate`) so a malformed shape is a build error, never a
/// silent skip. A detector with no `[detector.credential_shape]` maps to `None`
/// (no shape gate). There is no id list and no "unknown detector" case; the
/// shape rides on the detector's own spec, so it cannot name a detector that does
/// not exist.
pub(crate) fn compile_detector_shape_rule(
    detector: &DetectorSpec,
) -> Result<Option<CredentialShapeRule>, String> {
    let Some(shape) = detector.credential_shape.as_ref() else {
        return Ok(None);
    };
    shape.validate(&detector.id)?;
    Ok(Some(CredentialShapeRule::from_spec(shape)))
}

pub(crate) fn hydrate_detector_shape_rule(
    detector: &crate::execution_pack::detector_plan::DetectorPlanRecord,
) -> Result<Option<CredentialShapeRule>, String> {
    let Some(shape) = detector.credential_shape.as_ref() else {
        return Ok(None);
    };
    shape.validate(&detector.id)?;
    Ok(Some(CredentialShapeRule::from_spec(shape)))
}

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