talahon 3.0.0

Talahon — automated AI SOC analyst (host orchestrator)
Documentation
//! Collect the host's secret values for the [`Redactor`].
//!
//! Every outbound chat message and every log line the host writes passes
//! through one [`Redactor`] primed with these literal values, on top of the
//! redactor's built-in detectors (bearer tokens, API-key shapes, PEM keys,
//! `password=` assignments, ...).

use std::sync::Arc;

use talon_core::env::{EnvFile, current_key, key_spellings, parse_env};
use talon_security::redact::Redactor;

/// Secret keys Talahon itself reads (always registered when set, in `.env`
/// or the process environment). A `TALAHON_*` entry also covers its
/// pre-rename `TALON_*` spelling, see [`known_secret_key_spellings`].
pub const KNOWN_SECRET_KEYS: &[&str] = &[
    "TALAHON_VLLM_API_KEY",
    "TALAHON_ANTHROPIC_API_KEY",
    "HTTP_API_KEY",
    "TELEGRAM_BOT_TOKEN",
    "WEBHOOK_SECRET",
    "EMACS_AUTH_TOKEN",
    "TALAHON_AUDIT_KEY",
    "ONECLI_API_KEY",
    "ANTHROPIC_API_KEY",
    "CLAUDE_CODE_OAUTH_TOKEN",
    "OPENAI_API_KEY",
];

/// Substrings that mark any other `.env` key as secret.
const SECRET_KEY_MARKERS: &[&str] = &[
    "KEY",
    "TOKEN",
    "SECRET",
    "PASSWORD",
    "PASSWD",
    "CREDENTIAL",
    "PRIVATE",
];

/// [`KNOWN_SECRET_KEYS`] in every spelling a value may be stored under: each
/// key as listed plus, for a `TALAHON_*` key, the `TALON_*` name it had up to
/// 2.x. A secret left under the old key name is redacted like any other.
pub fn known_secret_key_spellings() -> Vec<String> {
    KNOWN_SECRET_KEYS
        .iter()
        .flat_map(|key| key_spellings(key))
        .collect()
}

/// Whether an environment key names a secret (`*KEY*`, `*TOKEN*`,
/// `*SECRET*`, `*PASSWORD*`, ...), under its current or its old spelling.
pub fn is_secret_key(key: &str) -> bool {
    let upper = key.to_ascii_uppercase();
    let current = current_key(&upper);
    KNOWN_SECRET_KEYS.contains(&upper.as_str())
        || current
            .as_deref()
            .is_some_and(|k| KNOWN_SECRET_KEYS.contains(&k))
        || SECRET_KEY_MARKERS.iter().any(|m| upper.contains(m))
}

/// Values that cannot be secrets (and would otherwise be scrubbed from
/// every log line): empty, numbers, booleans.
fn looks_like_secret_value(value: &str) -> bool {
    let v = value.trim();
    !v.is_empty()
        && !v.chars().all(|c| c.is_ascii_digit() || c == '.')
        && !matches!(
            v.to_ascii_lowercase().as_str(),
            "true" | "false" | "yes" | "no" | "on" | "off"
        )
}

/// Secret values from `.env` (every key that [`is_secret_key`]) plus the
/// [`KNOWN_SECRET_KEYS`] from the process environment, each under both its
/// current and its old spelling (when both are set, both values count).
pub fn collect_secrets(env: &EnvFile) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    if let Some(path) = env.path()
        && let Ok(content) = std::fs::read_to_string(path)
    {
        out.extend(
            parse_env(&content)
                .into_iter()
                .filter(|(k, _)| is_secret_key(k))
                .map(|(_, v)| v),
        );
    }
    // Every spelling is looked up by itself: a value under the old name is
    // a secret even when the new name is set as well.
    for key in known_secret_key_spellings() {
        if let Some(v) = env.file_value(&key) {
            out.push(v.to_string());
        }
        if let Ok(v) = std::env::var(&key) {
            out.push(v);
        }
    }
    out.retain(|v| looks_like_secret_value(v));
    out.sort();
    out.dedup();
    out
}

/// A shared redactor primed with [`collect_secrets`].
pub fn redactor_for(env: &EnvFile) -> Arc<Redactor> {
    Arc::new(Redactor::with_secrets(&collect_secrets(env)))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn secret_key_detection() {
        assert!(is_secret_key("TALAHON_VLLM_API_KEY"));
        assert!(is_secret_key("HTTP_API_KEY"));
        assert!(is_secret_key(talon_db::AUDIT_KEY_ENV));
        assert!(KNOWN_SECRET_KEYS.contains(&talon_db::AUDIT_KEY_ENV));
        assert!(is_secret_key("MYSQL_PASSWORD"));
        assert!(is_secret_key("some_token"));
        assert!(!is_secret_key("TALAHON_VLLM_MODEL"));
        assert!(!is_secret_key("TZ"));
    }

    #[test]
    fn collects_secret_values_from_env_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join(".env"),
            "TALAHON_VLLM_API_KEY=vllm-secret-123456\nHTTP_API_KEY=http-secret-abcdef\n\
             TALAHON_VLLM_MODEL=qwen\nCUSTOM_DB_PASSWORD=hunter2hunter2\n\
             TALAHON_VLLM_MAX_TOKENS=131072\nSOME_TOKEN_REQUIRED=true\n\
             TALAHON_AUDIT_KEY=4c1f0e9a7b3d5c2e8f6a1b0d9c7e5f3a2b4d6c8e0f1a3b5c7d9e1f2a4b6c8d0e\n",
        )
        .unwrap();
        let env = EnvFile::load(dir.path());
        let secrets = collect_secrets(&env);
        assert!(secrets.contains(&"vllm-secret-123456".to_string()));
        assert!(secrets.contains(&"http-secret-abcdef".to_string()));
        assert!(secrets.contains(&"hunter2hunter2".to_string()));
        assert!(!secrets.contains(&"qwen".to_string()));
        assert!(
            !secrets.contains(&"131072".to_string()),
            "numbers are not secrets"
        );
        assert!(!secrets.contains(&"true".to_string()));

        let redactor = redactor_for(&env);
        let out = redactor.redact("key is vllm-secret-123456 and model qwen");
        assert!(!out.contains("vllm-secret-123456"));
        assert!(out.contains("qwen"));
        // The audit key is scrubbed from logs and outbound text like any
        // other secret.
        let audit_key = "4c1f0e9a7b3d5c2e8f6a1b0d9c7e5f3a2b4d6c8e0f1a3b5c7d9e1f2a4b6c8d0e";
        assert!(secrets.contains(&audit_key.to_string()));
        let line = format!("audit key {audit_key} leaked");
        let out = redactor.redact(&line);
        assert!(!out.contains(audit_key), "{out}");
    }

    #[test]
    fn secrets_under_the_old_key_names_are_still_redacted() {
        // Upgraded installation: `.env` still spells the keys `TALON_*`,
        // one of them next to a different value under the new name.
        let env = EnvFile::from_str_content(
            "TALON_VLLM_API_KEY=old-vllm-secret-123456\n\
             TALON_ANTHROPIC_API_KEY=old-anthropic-secret-1\n\
             TALAHON_ANTHROPIC_API_KEY=new-anthropic-secret-2\n\
             TALON_AUDIT_KEY=old-audit-key-material-000\n",
        );
        assert!(is_secret_key("TALON_VLLM_API_KEY"));
        assert!(is_secret_key("TALON_AUDIT_KEY"));
        assert!(known_secret_key_spellings().contains(&"TALON_AUDIT_KEY".to_string()));
        assert!(known_secret_key_spellings().contains(&"TALAHON_AUDIT_KEY".to_string()));
        assert!(!known_secret_key_spellings().contains(&"TALON_HTTP_API_KEY".to_string()));

        let secrets = collect_secrets(&env);
        for secret in [
            "old-vllm-secret-123456",
            "old-anthropic-secret-1",
            "new-anthropic-secret-2",
            "old-audit-key-material-000",
        ] {
            assert!(secrets.contains(&secret.to_string()), "{secret}");
        }
        let redactor = redactor_for(&env);
        let out = redactor.redact("leak old-vllm-secret-123456 and old-anthropic-secret-1");
        assert!(!out.contains("old-vllm-secret-123456"), "{out}");
        assert!(!out.contains("old-anthropic-secret-1"), "{out}");
    }
}