feat: project skeleton, CLI surface, generate command, secret redaction, and CI matrix
- Create Rust binary crate with clap CLI (listen, connect, generate, version) - Implement generate command: CA/server/client certs, auth token, config.json - Strict secret redaction: Redacted type, PEM/token/config redaction utilities - Configurable addresses and ports with validation (fails before sockets open) - GitHub Actions CI matrix: Windows/Linux/macOS with fmt, clippy, test, build, release-build, and smoke check jobs - 39 unit tests covering CLI parsing, redaction, config, and credential generation - Cross-platform safe paths and clean-room implementation
This commit is contained in:
+238
@@ -0,0 +1,238 @@
|
||||
use std::fmt;
|
||||
|
||||
/// A wrapper type that redacts sensitive values when displayed or logged.
|
||||
/// Used for private keys, auth tokens, and session secrets.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Redacted(String);
|
||||
|
||||
impl Redacted {
|
||||
/// Create a new Redacted value.
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// Get the inner value. Use only when the value must be used programmatically
|
||||
/// (e.g., written to a file). Never pass this directly to logging.
|
||||
#[allow(dead_code)]
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Get a reference to the inner value. Use with caution.
|
||||
#[allow(dead_code)]
|
||||
pub fn inner(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Redacted {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let len = self.0.len();
|
||||
if len == 0 {
|
||||
write!(f, "[REDACTED]")
|
||||
} else if len <= 4 {
|
||||
write!(f, "[REDACTED(len={})]", len)
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"[REDACTED(len={}...{})]",
|
||||
&self.0[..2],
|
||||
&self.0[len - 2..]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Redacted {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Redacted {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Redacted {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Redact a PEM certificate or key block for safe logging.
|
||||
/// Returns a string showing only the block type and line count.
|
||||
#[allow(dead_code)]
|
||||
pub fn redact_pem(pem: &str) -> String {
|
||||
let lines = pem.lines().count();
|
||||
// Extract block type
|
||||
let block_type = pem
|
||||
.lines()
|
||||
.find(|l| l.starts_with("-----BEGIN "))
|
||||
.map(|l| {
|
||||
l.strip_prefix("-----BEGIN ")
|
||||
.and_then(|s| s.strip_suffix(" -----"))
|
||||
.unwrap_or("UNKNOWN")
|
||||
})
|
||||
.unwrap_or("UNKNOWN");
|
||||
format!("[REDACTED-PEM type={} lines={}]", block_type, lines)
|
||||
}
|
||||
|
||||
/// Redact a JSON configuration containing sensitive fields.
|
||||
/// Returns a display-safe representation.
|
||||
#[allow(dead_code)]
|
||||
pub fn redact_config_json(json: &str) -> String {
|
||||
let sensitive_keys = ["auth_token", "token", "private_key", "secret"];
|
||||
let mut val = json.to_string();
|
||||
for key in &sensitive_keys {
|
||||
let pat = format!("\"{}\":\"", key);
|
||||
while let Some(start) = val.find(&pat) {
|
||||
let after = &val[start + pat.len()..];
|
||||
// Skip if already redacted
|
||||
if after.starts_with("[REDACTED]\"") {
|
||||
break;
|
||||
}
|
||||
let end = after
|
||||
.find('"')
|
||||
.map(|p| start + pat.len() + p + 1)
|
||||
.unwrap_or(val.len());
|
||||
let replacement = format!("\"{}\":\"[REDACTED]\"", key);
|
||||
val.replace_range(start..end, &replacement);
|
||||
}
|
||||
}
|
||||
val
|
||||
}
|
||||
|
||||
/// Check if a string looks like a private key and should be redacted.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_sensitive(content: &str) -> bool {
|
||||
content.contains("-----BEGIN")
|
||||
&& (content.contains("PRIVATE-KEY")
|
||||
|| content.contains("PRIVATE KEY")
|
||||
|| content.contains("EC PRIVATE-KEY")
|
||||
|| content.contains("EC PRIVATE KEY")
|
||||
|| content.contains("RSA PRIVATE-KEY")
|
||||
|| content.contains("RSA PRIVATE KEY"))
|
||||
}
|
||||
|
||||
/// Check if a string looks like an auth token and should be redacted.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_auth_token(content: &str) -> bool {
|
||||
content.len() > 16
|
||||
&& content
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacted_display_hides_value() {
|
||||
let secret = Redacted::new("my-secret-token-12345");
|
||||
let display = format!("{}", secret);
|
||||
assert!(display.contains("REDACTED"));
|
||||
assert!(!display.contains("my-secret"));
|
||||
assert!(!display.contains("token"));
|
||||
// Should show length hints
|
||||
assert!(display.contains("len="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_empty_shows_marker() {
|
||||
let empty = Redacted::new("");
|
||||
assert_eq!(format!("{}", empty), "[REDACTED]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_short_shows_length() {
|
||||
let short = Redacted::new("abc");
|
||||
let display = format!("{}", short);
|
||||
assert!(display.contains("REDACTED"));
|
||||
assert!(!display.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_preserves_value_for_comparison() {
|
||||
let a = Redacted::new("same-value");
|
||||
let b = Redacted::new("same-value");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_inner_returns_value() {
|
||||
let secret = Redacted::new("real-value");
|
||||
assert_eq!(secret.inner(), "real-value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_pem_hides_content() {
|
||||
let pem = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBg...\n-----END PRIVATE KEY-----";
|
||||
let redacted = redact_pem(pem);
|
||||
assert!(redacted.contains("REDACTED"));
|
||||
assert!(!redacted.contains("MIIEvg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_config_json_hides_tokens() {
|
||||
let json = r#"{"auth_token":"secret123","listen":"127.0.0.1:4180"}"#;
|
||||
let redacted = redact_config_json(json);
|
||||
assert!(redacted.contains("REDACTED"));
|
||||
assert!(!redacted.contains("secret123"));
|
||||
// Non-sensitive fields should remain
|
||||
assert!(redacted.contains("127.0.0.1:4180"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sensitive_detects_private_key() {
|
||||
let pem = "-----BEGIN PRIVATE KEY-----\ndata";
|
||||
assert!(is_sensitive(pem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sensitive_detects_ec_private_key() {
|
||||
let pem = "-----BEGIN EC PRIVATE KEY-----\ndata";
|
||||
assert!(is_sensitive(pem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sensitive_false_for_certificate() {
|
||||
let pem = "-----BEGIN CERTIFICATE-----\ndata";
|
||||
assert!(!is_sensitive(pem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_auth_token_long_alphanumeric() {
|
||||
assert!(is_auth_token("abcdef1234567890ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_auth_token_short_is_false() {
|
||||
assert!(!is_auth_token("short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_logs_redact_secrets() {
|
||||
// Verify that tracing with a Redacted value doesn't leak secrets
|
||||
let secret = Redacted::new("sensitive-data");
|
||||
// The display impl ensures the formatted output is redacted
|
||||
let display = format!("{}", secret);
|
||||
assert!(!display.contains("sensitive-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_creates_redacted() {
|
||||
let r: Redacted = "test-value".into();
|
||||
assert_eq!(r.inner(), "test-value");
|
||||
assert!(!format!("{}", r).contains("test-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_string_creates_redacted() {
|
||||
let s = String::from("another-value");
|
||||
let r: Redacted = s.into();
|
||||
assert!(!format!("{}", r).contains("another-value"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user