@@ -154,6 +154,14 @@ pub fn dump_defaults() -> JsonValue {
154154///
155155/// Any JSON field whose name (lowercased) contains one of these
156156/// substrings will have its value replaced with `"***REDACTED***"`.
157+ /// Field name patterns that trigger automatic redaction.
158+ ///
159+ /// Any JSON field whose name (lowercased) contains one of these
160+ /// substrings will have its value replaced with `"***REDACTED***"`.
161+ ///
162+ /// This is a safety net — the primary protection is [`SensitiveString`]
163+ /// on the field type (compile-time safe). This heuristic catches fields
164+ /// that developers forgot to mark as sensitive.
157165const SENSITIVE_PATTERNS : & [ & str ] = & [
158166 "password" ,
159167 "secret" ,
@@ -164,6 +172,8 @@ const SENSITIVE_PATTERNS: &[&str] = &[
164172 "private" ,
165173 "cert" ,
166174 "encryption" ,
175+ "connection_string" ,
176+ "dsn" ,
167177] ;
168178
169179const REDACTED : & str = "***REDACTED***" ;
@@ -531,6 +541,271 @@ mod tests {
531541 assert_eq ! ( get_section( "fresh" ) . unwrap( ) . effective[ "enabled" ] , true ) ;
532542 }
533543
544+ // ── Redaction test structs (module-level to avoid items_after_statements) ──
545+
546+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
547+ struct MixedCase {
548+ #[ serde( rename = "Password" ) ]
549+ password_upper : String ,
550+ #[ serde( rename = "API_TOKEN" ) ]
551+ token_upper : String ,
552+ #[ serde( rename = "mySecret" ) ]
553+ secret_camel : String ,
554+ }
555+
556+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
557+ struct DeepNested {
558+ level1 : Level1 ,
559+ }
560+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
561+ struct Level1 {
562+ level2 : Level2 ,
563+ name : String ,
564+ }
565+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
566+ struct Level2 {
567+ api_token : String ,
568+ db_password : String ,
569+ port : u16 ,
570+ }
571+
572+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
573+ struct WithArray {
574+ items : Vec < ArrayItem > ,
575+ }
576+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
577+ struct ArrayItem {
578+ name : String ,
579+ secret_key : String ,
580+ }
581+
582+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize ) ]
583+ struct WithDefaultSecret {
584+ api_token : String ,
585+ host : String ,
586+ }
587+ impl Default for WithDefaultSecret {
588+ fn default ( ) -> Self {
589+ Self {
590+ api_token : "default-placeholder-token" . into ( ) ,
591+ host : "localhost" . into ( ) ,
592+ }
593+ }
594+ }
595+
596+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
597+ struct DoubleProtected {
598+ #[ serde( skip_serializing) ]
599+ #[ allow( dead_code) ]
600+ hidden_secret : String ,
601+ visible_token : String ,
602+ normal : String ,
603+ }
604+
605+ // ── Redaction guarantee tests ──────────────────────────────
606+
607+ /// Config struct that exercises ALL sensitive field name patterns.
608+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize , Default ) ]
609+ struct AllSensitivePatterns {
610+ // Each SENSITIVE_PATTERNS entry must be covered
611+ my_password : String ,
612+ db_secret : String ,
613+ api_token : String ,
614+ encryption_key : String ,
615+ aws_credential : String ,
616+ oauth_auth_code : String ,
617+ private_data : String ,
618+ tls_cert_path : String ,
619+ // Non-sensitive controls (must NOT be redacted)
620+ hostname : String ,
621+ port : u16 ,
622+ enabled : bool ,
623+ timeout_ms : u64 ,
624+ }
625+
626+ #[ test]
627+ fn redaction_covers_all_sensitive_patterns ( ) {
628+ serial_test ! ( ) ;
629+
630+ let config = AllSensitivePatterns {
631+ my_password : "pass123" . into ( ) ,
632+ db_secret : "sec456" . into ( ) ,
633+ api_token : "tok789" . into ( ) ,
634+ encryption_key : "key012" . into ( ) ,
635+ aws_credential : "cred345" . into ( ) ,
636+ oauth_auth_code : "auth678" . into ( ) ,
637+ private_data : "priv901" . into ( ) ,
638+ tls_cert_path : "/etc/tls/cert.pem" . into ( ) ,
639+ hostname : "db.prod.internal" . into ( ) ,
640+ port : 5432 ,
641+ enabled : true ,
642+ timeout_ms : 30000 ,
643+ } ;
644+ register :: < AllSensitivePatterns > ( "all_patterns" , & config) ;
645+
646+ let dump = dump_effective ( ) ;
647+ let section = & dump[ "all_patterns" ] ;
648+
649+ // Every sensitive field MUST be redacted
650+ assert_eq ! ( section[ "my_password" ] , REDACTED , "password pattern missed" ) ;
651+ assert_eq ! ( section[ "db_secret" ] , REDACTED , "secret pattern missed" ) ;
652+ assert_eq ! ( section[ "api_token" ] , REDACTED , "token pattern missed" ) ;
653+ assert_eq ! ( section[ "encryption_key" ] , REDACTED , "key pattern missed" ) ;
654+ assert_eq ! (
655+ section[ "aws_credential" ] , REDACTED ,
656+ "credential pattern missed"
657+ ) ;
658+ assert_eq ! ( section[ "oauth_auth_code" ] , REDACTED , "auth pattern missed" ) ;
659+ assert_eq ! ( section[ "private_data" ] , REDACTED , "private pattern missed" ) ;
660+ assert_eq ! ( section[ "tls_cert_path" ] , REDACTED , "cert pattern missed" ) ;
661+
662+ // Non-sensitive fields MUST be preserved
663+ assert_eq ! ( section[ "hostname" ] , "db.prod.internal" ) ;
664+ assert_eq ! ( section[ "port" ] , 5432 ) ;
665+ assert_eq ! ( section[ "enabled" ] , true ) ;
666+ assert_eq ! ( section[ "timeout_ms" ] , 30000 ) ;
667+ }
668+
669+ #[ test]
670+ fn redaction_is_case_insensitive ( ) {
671+ serial_test ! ( ) ;
672+
673+ let config = MixedCase {
674+ password_upper : "visible_if_broken" . into ( ) ,
675+ token_upper : "visible_if_broken" . into ( ) ,
676+ secret_camel : "visible_if_broken" . into ( ) ,
677+ } ;
678+ register :: < MixedCase > ( "case_test" , & config) ;
679+
680+ let dump = dump_effective ( ) ;
681+ let section = & dump[ "case_test" ] ;
682+
683+ assert_eq ! ( section[ "Password" ] , REDACTED ) ;
684+ assert_eq ! ( section[ "API_TOKEN" ] , REDACTED ) ;
685+ assert_eq ! ( section[ "mySecret" ] , REDACTED ) ;
686+ }
687+
688+ #[ test]
689+ fn redaction_handles_deeply_nested_secrets ( ) {
690+ serial_test ! ( ) ;
691+
692+ let config = DeepNested {
693+ level1 : Level1 {
694+ level2 : Level2 {
695+ api_token : "deep_secret_1" . into ( ) ,
696+ db_password : "deep_secret_2" . into ( ) ,
697+ port : 3306 ,
698+ } ,
699+ name : "safe_value" . into ( ) ,
700+ } ,
701+ } ;
702+ register :: < DeepNested > ( "deep" , & config) ;
703+
704+ let dump = dump_effective ( ) ;
705+ assert_eq ! ( dump[ "deep" ] [ "level1" ] [ "level2" ] [ "api_token" ] , REDACTED ) ;
706+ assert_eq ! ( dump[ "deep" ] [ "level1" ] [ "level2" ] [ "db_password" ] , REDACTED ) ;
707+ assert_eq ! ( dump[ "deep" ] [ "level1" ] [ "level2" ] [ "port" ] , 3306 ) ;
708+ assert_eq ! ( dump[ "deep" ] [ "level1" ] [ "name" ] , "safe_value" ) ;
709+ }
710+
711+ #[ test]
712+ fn redaction_handles_arrays_with_sensitive_objects ( ) {
713+ serial_test ! ( ) ;
714+
715+ let config = WithArray {
716+ items : vec ! [
717+ ArrayItem {
718+ name: "item1" . into( ) ,
719+ secret_key: "sk_1" . into( ) ,
720+ } ,
721+ ArrayItem {
722+ name: "item2" . into( ) ,
723+ secret_key: "sk_2" . into( ) ,
724+ } ,
725+ ] ,
726+ } ;
727+ register :: < WithArray > ( "array_test" , & config) ;
728+
729+ let dump = dump_effective ( ) ;
730+ let items = dump[ "array_test" ] [ "items" ] . as_array ( ) . unwrap ( ) ;
731+ for item in items {
732+ assert_eq ! ( item[ "secret_key" ] , REDACTED ) ;
733+ assert_ne ! ( item[ "name" ] , REDACTED ) ; // name should be preserved
734+ }
735+ }
736+
737+ #[ test]
738+ fn no_secret_values_in_redacted_dump_string ( ) {
739+ serial_test ! ( ) ;
740+
741+ let secrets = [
742+ "hunter2" ,
743+ "sk_live_abc123" ,
744+ "super_s3cret!" ,
745+ "my-private-key-data" ,
746+ ] ;
747+
748+ let config = AllSensitivePatterns {
749+ my_password : secrets[ 0 ] . into ( ) ,
750+ db_secret : secrets[ 1 ] . into ( ) ,
751+ api_token : secrets[ 2 ] . into ( ) ,
752+ encryption_key : secrets[ 3 ] . into ( ) ,
753+ ..Default :: default ( )
754+ } ;
755+ register :: < AllSensitivePatterns > ( "leak_check" , & config) ;
756+
757+ // Serialise the full dump to a string and scan for ANY secret value
758+ let dump = dump_effective ( ) ;
759+ let dump_str = serde_json:: to_string ( & dump) . unwrap ( ) ;
760+
761+ for secret in & secrets {
762+ assert ! (
763+ !dump_str. contains( secret) ,
764+ "SECRET LEAKED in dump_effective(): '{secret}' found in output"
765+ ) ;
766+ }
767+ }
768+
769+ #[ test]
770+ fn defaults_dump_also_redacted ( ) {
771+ serial_test ! ( ) ;
772+
773+ register :: < WithDefaultSecret > ( "default_secrets" , & WithDefaultSecret :: default ( ) ) ;
774+
775+ let dump = dump_defaults ( ) ;
776+ assert_eq ! ( dump[ "default_secrets" ] [ "api_token" ] , REDACTED ) ;
777+ assert_eq ! ( dump[ "default_secrets" ] [ "host" ] , "localhost" ) ;
778+ }
779+
780+ #[ test]
781+ fn skip_serializing_plus_heuristic_double_protection ( ) {
782+ serial_test ! ( ) ;
783+
784+ let config = DoubleProtected {
785+ hidden_secret : "should_not_appear" . into ( ) ,
786+ visible_token : "should_be_redacted" . into ( ) ,
787+ normal : "visible" . into ( ) ,
788+ } ;
789+ register :: < DoubleProtected > ( "double" , & config) ;
790+
791+ let dump = dump_effective ( ) ;
792+ let section = & dump[ "double" ] ;
793+
794+ // skip_serializing: field absent entirely
795+ assert ! ( section. get( "hidden_secret" ) . is_none( ) ) ;
796+ // heuristic: field present but redacted
797+ assert_eq ! ( section[ "visible_token" ] , REDACTED ) ;
798+ // normal: preserved
799+ assert_eq ! ( section[ "normal" ] , "visible" ) ;
800+
801+ // String scan: neither secret should appear
802+ let dump_str = serde_json:: to_string ( & dump) . unwrap ( ) ;
803+ assert ! ( !dump_str. contains( "should_not_appear" ) ) ;
804+ assert ! ( !dump_str. contains( "should_be_redacted" ) ) ;
805+ }
806+
807+ // ── Change notification ─────────────────────────────────────
808+
534809 #[ test]
535810 fn multiple_listeners_on_same_key ( ) {
536811 serial_test ! ( ) ;
0 commit comments