rust-sanitize 0.14.1

Deterministic one-way data sanitization engine
Documentation
use std::sync::Arc;
use tracing::{info, warn};
use zeroize::Zeroizing;

use rust_sanitize::secrets::{entries_to_patterns, parse_category, SecretEntry};
use rust_sanitize::{
    FieldNameSignal, HmacGenerator, MappingStore, RandomGenerator, ReplacementGenerator,
    ScanConfig, ScanPattern, StreamScanner, DEFAULT_FIELD_SIGNAL_THRESHOLD,
};

/// Build an `Arc<MappingStore>` with the chosen generator mode.
pub(crate) fn build_store(
    deterministic: bool,
    password: Option<&str>,
    max_mappings: usize,
    allowlist: Option<Arc<rust_sanitize::allowlist::AllowlistMatcher>>,
) -> std::result::Result<Arc<MappingStore>, String> {
    let generator: Arc<dyn ReplacementGenerator> = if deterministic {
        match password {
            Some(k) => {
                use hmac::Hmac;
                use sha2::Sha256;
                let mut buf = Zeroizing::new([0u8; 32]);
                let salt = b"rust-sanitize:deterministic-seed:v1";
                pbkdf2::pbkdf2::<Hmac<Sha256>>(k.as_bytes(), salt, 600_000, buf.as_mut())
                    .expect("PBKDF2 output length is valid");
                Arc::new(HmacGenerator::new(*buf))
            }
            None => {
                return Err(
                    "--deterministic requires --password (or SANITIZE_PASSWORD). \
                     A deterministic seed cannot be derived without a key."
                        .into(),
                );
            }
        }
    } else {
        Arc::new(RandomGenerator::new())
    };
    let capacity = if max_mappings == 0 {
        None
    } else {
        Some(max_mappings)
    };
    Ok(Arc::new(match allowlist {
        Some(al) => MappingStore::new_with_allowlist(generator, capacity, al),
        None => MappingStore::new(generator, capacity),
    }))
}

/// Common values that are safe to allow through for any built-in preset.
pub(crate) fn common_allow_patterns() -> Vec<String> {
    vec![
        "127.0.0.1".into(),
        "0.0.0.0".into(),
        "255.255.255.255".into(),
        "255.255.255.0".into(),
        "255.255.0.0".into(),
        "255.0.0.0".into(),
        "::1".into(),
        "localhost".into(),
        "localhost.localdomain".into(),
        "http://localhost*".into(),
        "https://localhost*".into(),
        "http://127.0.0.1*".into(),
        "https://127.0.0.1*".into(),
        "example.com".into(),
        "example.org".into(),
        "example.net".into(),
        "http://example.com*".into(),
        "https://example.com*".into(),
        "https://example.org*".into(),
        "https://example.net*".into(),
        "00000000-0000-0000-0000-000000000000".into(),
        "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".into(),
        "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into(),
        "12345678-1234-1234-1234-123456789abc".into(),
        "changeme".into(),
        "example".into(),
        "sample".into(),
        "placeholder".into(),
        "${*}".into(),
        "{{*}}".into(),
    ]
}

/// Build the canonical balanced set of `SecretEntry` values.
///
/// Used both to compile the in-memory scanner and to write the starter
/// `~/.config/sanitize/secrets.yaml` on first run.
pub(crate) fn balanced_secret_entries() -> Vec<SecretEntry> {
    fn e(pattern: &str, category: &str, label: &str) -> SecretEntry {
        SecretEntry {
            pattern: pattern.to_string(),
            kind: "regex".to_string(),
            category: category.to_string(),
            label: Some(label.to_string()),
            values: vec![],
            min_length: None,
            max_length: None,
            threshold: None,
            charset: None,
        }
    }
    vec![
        e(
            r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
            "email",
            "email",
        ),
        e(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "ipv4", "ipv4"),
        e(
            r"\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b",
            "ipv6",
            "ipv6_full",
        ),
        e(
            r"\b(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{0,4}\b|\b::(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4}\b",
            "ipv6",
            "ipv6_compressed",
        ),
        e(
            r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b",
            "uuid",
            "uuid",
        ),
        e(
            r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b",
            "jwt",
            "jwt",
        ),
        // `credential_url` must precede the generic `url` detector: both match a
        // `https://user:pass@host` span to the same end, and on that tie the
        // earlier pattern wins — so the precise (capture-group) rule has to come
        // first, or every credential-bearing https URL is redacted whole.
        //
        // Capture group 1 is the `user:pass` credential only, so the scheme,
        // host, port, path, and query (`@db.host:5432/orders?sslmode=require`)
        // are preserved for troubleshooting — only the credential is redacted.
        // The username is optional (`{0,128}`) to catch the password-only form
        // `redis://:secret@host`, common for Redis and otherwise missed.
        e(
            r#"[a-z][a-z0-9+.-]+://([^:@\s]{0,128}:[^@\s]{1,128})@[^\s"'<>]+"#,
            "url",
            "credential_url",
        ),
        e(r#"https?://[^\s"'<>;]+"#, "url", "url"),
        e(
            r"-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----",
            "auth_token",
            "private_key_header",
        ),
        // Capture group 1 is the value only, so the `key=` keyword and any
        // trailing structured context (`,ssl=True`, `&sslmode=require`) are
        // preserved for troubleshooting — only the secret itself is redacted.
        e(
            r#"(?i)(?:api_key|api_secret|access_token|client_secret|private_key|secret_key|auth_key|signing_key|jwt_secret|jwt_key)[\s:="']+([A-Za-z0-9._~+/=-]{16,})"#,
            "auth_token",
            "secret_kv",
        ),
        // Value class excludes the structured separators `, ; &` so a
        // connection-string `password=secret,ssl=True` redacts only `secret`
        // and keeps `,ssl=True`. Capture group 1 keeps the `password=` keyword.
        e(
            r#"(?i)(?:password|passwd|pwd)[\s:="']+([^\s"',;&]{6,})"#,
            "custom:password",
            "password_kv",
        ),
        // Capture only the username segment so the `/home/` prefix and the rest
        // of the path are preserved (`/home/alice/x` → `/home/‹token›/x`). No
        // `.` in the charset: POSIX usernames never contain one, and excluding
        // it stops the match from swallowing file paths like `/home/foo.html`.
        e(
            r"/(?:home|Users)/([A-Za-z0-9_-]+)",
            "file_path",
            "user_home_path",
        ),
        e(r"\bsha256:[a-f0-9]{64}\b", "container_id", "image_digest"),
        e(
            r"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b",
            "mac_address",
            "mac_address",
        ),
        e(
            r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b",
            "auth_token",
            "github_token",
        ),
        e(
            r"\bgithub_pat_[A-Za-z0-9_]{82}\b",
            "auth_token",
            "github_pat_fine_grained",
        ),
        e(r"\bAIza[A-Za-z0-9_-]{35}\b", "auth_token", "gcp_api_key"),
        // Full set of AWS unique-ID prefixes (access keys, STS, roles, users,
        // groups, etc.) so bundles don't each re-implement an AWS key rule.
        e(
            r"\b(?:ABIA|ACCA|AGPA|AIDA|AIPA|AKIA|ANPA|ANVA|APKA|AROA|ASCA|ASIA)[A-Z0-9]{16}\b",
            "auth_token",
            "aws_access_key_id",
        ),
        e(
            r"\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{40,}\b",
            "auth_token",
            "openai_api_key",
        ),
        e(
            r"\bsk-ant-[A-Za-z0-9_-]{93,}\b",
            "auth_token",
            "anthropic_api_key",
        ),
        e(
            r"\bxox[bpars]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*\b",
            "auth_token",
            "slack_token",
        ),
        e(r"\bnpm_[A-Za-z0-9]{36}\b", "auth_token", "npm_token"),
        e(r"\bhf_[A-Za-z0-9]{34}\b", "auth_token", "huggingface_token"),
        e(
            r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,}\b",
            "auth_token",
            "stripe_key",
        ),
        e(r"\bglpat-[A-Za-z0-9_-]{20}\b", "auth_token", "gitlab_token"),
        e(
            r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b",
            "auth_token",
            "sendgrid_api_key",
        ),
        e(r"\bAC[a-f0-9]{32}\b", "auth_token", "twilio_account_sid"),
    ]
}

/// Compile the built-in balanced detection patterns.
pub(crate) fn build_default_patterns() -> Vec<ScanPattern> {
    let (patterns, errors) = entries_to_patterns(&balanced_secret_entries());
    if !errors.is_empty() {
        for (i, err) in &errors {
            warn!(entry = i, error = %err, "built-in default pattern failed to compile");
        }
    }
    patterns
}

/// Build the two built-in field-name signal groups.
pub(crate) fn builtin_field_name_signals() -> Vec<FieldNameSignal> {
    let specs: &[(&str, &str, f64)] = &[
        (
            r"password|passwd|secret|private_key|api_secret|client_secret",
            "field-signal:strong",
            3.0,
        ),
        (
            r"api_key|access_key|auth_token|token|signing_key|encryption_key|credential|cert",
            "field-signal:medium",
            3.5,
        ),
    ];
    specs
        .iter()
        .filter_map(|(pattern, label, threshold)| {
            match FieldNameSignal::new(
                *pattern,
                parse_category("custom:credential"),
                Some((*label).to_string()),
                *threshold,
            ) {
                Ok(sig) => Some(sig),
                Err(e) => {
                    warn!(error = %e, "built-in field-name signal failed to compile");
                    None
                }
            }
        })
        .collect()
}

/// Extract `kind: field-name` entries from a parsed secrets list and compile
/// them into [`FieldNameSignal`]s.
pub(crate) fn field_signals_from_entries(entries: &[SecretEntry]) -> Vec<FieldNameSignal> {
    entries
        .iter()
        .filter(|e| e.kind == "field-name" && !e.pattern.is_empty())
        .filter_map(|e| {
            let category = parse_category(&e.category);
            let threshold = e.threshold.unwrap_or(DEFAULT_FIELD_SIGNAL_THRESHOLD);
            match FieldNameSignal::new(&e.pattern, category, e.label.clone(), threshold) {
                Ok(sig) => Some(sig),
                Err(err) => {
                    warn!(pattern = %e.pattern, error = %err, "field-name signal skipped");
                    None
                }
            }
        })
        .collect()
}

/// Build an augmented scanner after the profile pass (Phase 1).
pub(crate) fn build_augmented_scanner(
    base_patterns: &[ScanPattern],
    store: &Arc<MappingStore>,
    scan_config: ScanConfig,
) -> std::result::Result<Arc<StreamScanner>, (String, i32)> {
    let mut patterns = base_patterns.to_vec();

    let mut discovered = 0usize;
    for (category, original, _replacement) in store.iter() {
        let s = original.as_str();
        if s.is_empty() {
            continue;
        }
        // Label by category, never the value — labels surface in the report,
        // `--findings`, and the console summary, which must contain no secrets.
        let label = format!("profile-discovered:{category}");
        match ScanPattern::from_literal(s, category, label) {
            Ok(pat) => {
                patterns.push(pat);
                discovered += 1;
            }
            Err(e) => {
                warn!(error = %e, "could not compile discovered literal pattern");
            }
        }
    }

    if discovered > 0 {
        info!(
            count = discovered,
            "augmented scanner with profile-discovered literals"
        );
    }

    let scanner = StreamScanner::new(patterns, Arc::clone(store), scan_config)
        .map_err(|e| (format!("failed to create augmented scanner: {e}"), 1))?;
    Ok(Arc::new(scanner))
}

/// Build a `ScanConfig`, validating `chunk_size`.
pub(crate) fn build_scan_config(chunk_size: usize) -> Result<ScanConfig, String> {
    if chunk_size == 0 {
        return Err("--chunk-size must be greater than 0".into());
    }
    let overlap = (chunk_size / 4).clamp(1, 4096);
    if overlap >= chunk_size {
        return Err(format!(
            "--chunk-size ({chunk_size}) is too small; must be > {overlap} bytes"
        ));
    }
    let cfg = ScanConfig::new(chunk_size, overlap);
    cfg.validate().map_err(|e| e.to_string())?;
    Ok(cfg)
}