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}