Warn callers not to log resolved secrets
The global tracing subscriber has no redaction layer, so the plain
String returned by SecretRegistry::resolve must not reach tracing or
Sentry. Document the contract at both API entry points and pin the
SecretRegistry Debug redaction with a regression test.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 704520b..5f75d10 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -194,6 +194,12 @@ impl Runtime {
/// Resolve a declared secret by name, caching it for redaction.
/// Errors if the name isn't declared or the secret's source
/// can't be read.
+ ///
+ /// The returned `String` is the plain, revealed value — never
+ /// trace or log it directly. See [`SecretRegistry::resolve`] for
+ /// the full caveat.
+ ///
+ /// [`SecretRegistry::resolve`]: crate::secret::SecretRegistry::resolve
pub(super) fn secret(&self, name: &str) -> super::error::Result<String> {
self.registry.borrow_mut().resolve(name).map_err(Into::into)
}
diff --git a/src/secret.rs b/src/secret.rs
index 40efeb3..512618a 100644
--- a/src/secret.rs
+++ b/src/secret.rs
@@ -219,6 +219,13 @@ impl SecretRegistry {
/// common short strings like "true" or "yes" is too high. A warn
/// is emitted so an operator can see why a short token is showing
/// up unredacted in CI output.
+ ///
+ /// The returned `String` is the plain, revealed value. Do not pass
+ /// it to `tracing` or any other log sink — the global tracing
+ /// subscriber has no redaction layer, so a leaked value would
+ /// reach stderr and Sentry. Route it into a surface that goes
+ /// through [`redact`] (e.g. `sh` command args, ShOutput) or wrap
+ /// it in a type whose `Debug`/`Display` impl redacts.
pub fn resolve(&mut self, name: &str) -> Result<String> {
let secret = self
.declared
@@ -289,6 +296,22 @@ mod tests {
);
}
+ #[test]
+ fn registry_debug_does_not_leak_revealed_values() {
+ let mut registry: SecretRegistry =
+ vec![("github_token", "abcdefghijklmnop_long_enough")].into();
+ let _ = registry.resolve("github_token").unwrap();
+ let debug_output = format!("{registry:?}");
+ assert!(
+ !debug_output.contains("abcdefghijklmnop_long_enough"),
+ "SecretRegistry Debug must not leak revealed values: {debug_output}"
+ );
+ assert!(
+ debug_output.contains("github_token"),
+ "SecretRegistry Debug should still surface declared names: {debug_output}"
+ );
+ }
+
#[test]
fn reveal_returns_plain_value() {
let secret = SecretString::from("plain_value");