keyhog-sources 0.5.44

keyhog-sources: pluggable input backends for KeyHog (git, S3, GCS, Azure Blob, Docker, Web)
Documentation
use regex::Regex;
use std::sync::atomic::{AtomicU64, Ordering};

/// Number of times a redaction regex failed to compile and the sanitizer
/// therefore refused to emit the credential-bearing git stderr.
pub(crate) static REDACTION_COMPILE_FAILURES: AtomicU64 = AtomicU64::new(0);

const REDACTION_FAILED_PLACEHOLDER: &str =
    "<git error suppressed: a credential-redaction pattern failed to compile; \
     raw stderr withheld to avoid leaking secrets>";

pub(crate) fn sanitize_git_error_message(stderr: &str) -> String {
    use std::sync::OnceLock;

    static URL_CRED_RE: OnceLock<Result<Regex, String>> = OnceLock::new();
    static AUTH_HEADER_RE: OnceLock<Result<Regex, String>> = OnceLock::new();
    static TOKEN_RE: OnceLock<Result<Regex, String>> = OnceLock::new();

    // `[^/\s]+@` (not `[^/@\s]+@`): the userinfo runs to the LAST `@` before the
    // path `/` or whitespace, so a git basic-auth password carrying a literal
    // `@` (`https://user:pa@ss@host/…`: git surfaces raw stderr even though RFC
    // 3986 says `@` should be percent-encoded) is redacted WHOLE. The former
    // `[^/@\s]+` stopped at the FIRST `@`, redacting only `user:pa@` and leaking
    // the `ss@host` tail, the same first-`@`-vs-last-`@` leak `url_redaction.rs`
    // fixes with `rfind`. The `/`-exclusion still confines the match to the
    // authority so a later `@` in the path is never treated as a userinfo end.
    let url_cred =
        URL_CRED_RE.get_or_init(|| compile_redaction_regex(r"([a-z][a-z0-9+\-.]*://)([^/\s]+)@"));
    let auth_header = AUTH_HEADER_RE
        .get_or_init(|| compile_redaction_regex(r"(?i)(authorization:\s*(?:basic|bearer)\s+)\S+"));
    let token_pat = TOKEN_RE.get_or_init(|| {
        compile_redaction_regex(r"(?:ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}|glpat-[A-Za-z0-9_-]{20,64}|glrt-[A-Za-z0-9_-]{16,64}|gldt-[A-Za-z0-9_-]{16,64}|glcbt-[A-Za-z0-9_-]{16,64}|xoxb-[A-Za-z0-9-]{24,}|xoxp-[A-Za-z0-9-]{24,}|sk-proj-[A-Za-z0-9_-]{24,}|sk_live_[A-Za-z0-9]{24,}|sk_test_[A-Za-z0-9]{24,}|AKIA[0-9A-Z]{16})")
    });

    let url_cred = regex_result_as_option(url_cred);
    let auth_header = regex_result_as_option(auth_header);
    let token_pat = regex_result_as_option(token_pat);

    redact_with(stderr, url_cred, auth_header, token_pat)
}

fn compile_redaction_regex(pattern: &str) -> Result<Regex, String> {
    Regex::new(pattern).map_err(|error| error.to_string())
}

fn regex_result_as_option(result: &Result<Regex, String>) -> Option<&Regex> {
    match result {
        Ok(regex) => Some(regex),
        Err(_error) => None,
    }
}

pub(crate) fn redact_with(
    stderr: &str,
    url_cred: Option<&Regex>,
    auth_header: Option<&Regex>,
    token_pat: Option<&Regex>,
) -> String {
    let (Some(url_cred), Some(auth_header), Some(token_pat)) = (url_cred, auth_header, token_pat)
    else {
        REDACTION_COMPILE_FAILURES.fetch_add(1, Ordering::Relaxed);
        eprintln!(
            "keyhog: SECURITY - a git-error credential-redaction regex failed to \
             compile; refusing to surface raw git stderr"
        );
        return REDACTION_FAILED_PLACEHOLDER.to_string();
    };

    let mut result = url_cred.replace_all(stderr, "${1}<redacted>@").into_owned();
    result = auth_header
        .replace_all(&result, "${1}<redacted>")
        .into_owned();
    result = token_pat
        .replace_all(&result, "<redacted-token>")
        .into_owned();
    result.trim().to_string()
}

#[cfg(test)]
pub(crate) const REDACTION_FAILED_PLACEHOLDER_FOR_TEST: &str = REDACTION_FAILED_PLACEHOLDER;

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