Skip to main content

kimetsu_core/
secret.rs

1//! v0.4.9: `SecretString` — a wrapper that hides its inner value
2//! from `Debug` / `Display` / `serde` so accidental
3//! `{:?}`/panic-backtrace/tracing dumps don't leak credentials.
4//!
5//! Why this exists:
6//!   `ClaudeCodeProvider` and `AnthropicProvider` previously held
7//!   `api_key: String` and derived `#[derive(Debug)]`. Any
8//!   `{:?}` print of the provider, any `tracing::debug!(?provider)`
9//!   call, any panic with the struct in the backtrace, would emit
10//!   the raw token. We never logged the providers in production
11//!   paths, but the latent risk was real — a `dbg!` left in a
12//!   debug session would have been enough to leak via stderr.
13//!
14//! Contract:
15//!   * `Debug` and `Display` always emit `"[REDACTED]"`. There is
16//!     no way to derive the value from format output.
17//!   * `serde::Serialize` emits the same redaction string. Brain
18//!     traces, MCP responses, and run JSONL files cannot persist
19//!     the inner value by accident.
20//!   * The value is only reachable via `expose_secret()`. Each
21//!     call site that legitimately needs the cleartext (subprocess
22//!     env, HTTP header) must explicitly opt in — and those
23//!     accesses are easy to grep for in code review.
24//!
25//! What this is NOT:
26//!   * Not memory-safe / not zeroizing — the underlying String
27//!     stays in heap allocations like any other String. For that
28//!     we'd reach for the `secrecy` / `zeroize` crates; v0.4.9
29//!     keeps the dependency footprint tight and addresses only
30//!     the actual incident vector (Debug + Display + serde).
31
32use std::fmt;
33
34/// Holds a credential and refuses to print it. Construct via
35/// `SecretString::new(value)` or `String::into`. Read with
36/// `expose_secret()` at the exact call site that needs the
37/// cleartext — those callers should be enumerable on `git grep`.
38#[derive(Clone, PartialEq, Eq)]
39pub struct SecretString(String);
40
41impl SecretString {
42    /// Wrap a plaintext credential. The inner String is moved in
43    /// and never reborrowed by reference outside this type.
44    pub fn new(value: impl Into<String>) -> Self {
45        Self(value.into())
46    }
47
48    /// Borrow the cleartext. Use sparingly — every call site is a
49    /// place where the secret could leak into logs.
50    pub fn expose_secret(&self) -> &str {
51        &self.0
52    }
53
54    /// True when the inner value is empty. Useful for "did the env
55    /// var resolve?" checks without exposing the value.
56    pub fn is_empty(&self) -> bool {
57        self.0.is_empty()
58    }
59
60    /// Length of the inner value. Used by diagnostic logs to
61    /// confirm "secret is present" without disclosing it.
62    pub fn len(&self) -> usize {
63        self.0.len()
64    }
65}
66
67impl fmt::Debug for SecretString {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        // Length included so panic backtraces still say something
70        // useful ("a 64-char token is present") without leaking
71        // the value.
72        write!(f, "SecretString([REDACTED; len={}])", self.0.len())
73    }
74}
75
76impl fmt::Display for SecretString {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str("[REDACTED]")
79    }
80}
81
82impl From<String> for SecretString {
83    fn from(value: String) -> Self {
84        Self::new(value)
85    }
86}
87
88impl From<&str> for SecretString {
89    fn from(value: &str) -> Self {
90        Self::new(value.to_string())
91    }
92}
93
94/// `Serialize` always emits the redaction marker so brain traces +
95/// MCP responses + run JSONL files cannot persist the cleartext.
96impl serde::Serialize for SecretString {
97    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
98    where
99        S: serde::Serializer,
100    {
101        serializer.serialize_str("[REDACTED]")
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn debug_format_never_includes_inner_value() {
111        let s = SecretString::new("sk-ant-api03-very-real-looking-but-not");
112        let dbg = format!("{:?}", s);
113        assert!(
114            !dbg.contains("sk-ant-api03"),
115            "Debug must NOT include the inner value: {dbg}"
116        );
117        assert!(dbg.contains("REDACTED"));
118        assert!(dbg.contains("len=38"));
119    }
120
121    #[test]
122    fn display_emits_redaction_marker() {
123        let s = SecretString::new("hunter2");
124        assert_eq!(format!("{}", s), "[REDACTED]");
125        assert!(!format!("{}", s).contains("hunter2"));
126    }
127
128    #[test]
129    fn serialize_emits_redaction_marker() {
130        let s = SecretString::new("sk-ant-api03-leaky-1234567890");
131        let json = serde_json::to_string(&s).expect("serialize");
132        assert_eq!(json, "\"[REDACTED]\"");
133        assert!(!json.contains("sk-ant"));
134    }
135
136    #[test]
137    fn expose_secret_returns_cleartext() {
138        let s = SecretString::new("real-value");
139        assert_eq!(s.expose_secret(), "real-value");
140    }
141
142    #[test]
143    fn empty_and_len_helpers() {
144        assert!(SecretString::new("").is_empty());
145        let s = SecretString::new("abcd");
146        assert!(!s.is_empty());
147        assert_eq!(s.len(), 4);
148    }
149
150    /// Regression guard: when a struct holding a SecretString is
151    /// Debug-printed, the secret stays redacted EVEN INSIDE the
152    /// parent struct's derived Debug output.
153    #[test]
154    fn parent_struct_derive_debug_does_not_leak() {
155        #[derive(Debug)]
156        struct Provider {
157            api_key: SecretString,
158            model: String,
159        }
160        let p = Provider {
161            api_key: SecretString::new("sk-ant-api03-DEFINITELY-LEAKED-IF-BROKEN"),
162            model: "claude-opus".into(),
163        };
164        let dbg = format!("{:?}", p);
165        assert!(
166            !dbg.contains("DEFINITELY-LEAKED-IF-BROKEN"),
167            "parent struct's derived Debug must NOT leak nested SecretString: {dbg}"
168        );
169        assert!(dbg.contains("REDACTED"));
170        assert!(dbg.contains("claude-opus"), "non-secret fields should print");
171    }
172}