@@ -57,11 +57,13 @@ const (
5757
5858// defaultRedactionPatterns contains regex patterns for redacting sensitive data in logs
5959var defaultRedactionPatterns = []* regexp.Regexp {
60- // Element content
61- regexp .MustCompile (`<password>.*?</password>` ),
62- regexp .MustCompile (`<secret>.*?</secret>` ),
63- regexp .MustCompile (`<key>.*?</key>` ),
64- regexp .MustCompile (`<community>.*?</community>` ),
60+ // Element content - Handle nested structures (Cisco YANG models use container/value nesting)
61+ // Match greedy to capture nested structures: <password>...<password>...</password></password>
62+ // The [\s\S] matches any character including newlines
63+ regexp .MustCompile (`<password>[\s\S]*?</password>` ),
64+ regexp .MustCompile (`<secret>[\s\S]*?</secret>` ),
65+ regexp .MustCompile (`<key>[\s\S]*?</key>` ),
66+ regexp .MustCompile (`<community>[\s\S]*?</community>` ),
6567
6668 // CDATA section handling (must come before namespace-aware to avoid conflicts)
6769 // Matches: <password><![CDATA[value]]></password>
@@ -739,13 +741,33 @@ func (c *Client) HasCredentials() bool {
739741 return c .username != "" || c .password != "" || c .SSHKeyPath != ""
740742}
741743
744+ // formatXMLForLogging adds a leading newline to separate XML from log metadata
745+ //
746+ // Adds a newline at the start to make multi-line XML more readable in logs.
747+ //
748+ // Example output:
749+ //
750+ // [DEBUG] NETCONF RPC request XML operation=get-config
751+ // <get-config>
752+ // <source>
753+ // <running></running>
754+ // </source>
755+ // </get-config>
756+ //
757+ // Returns the formatted XML string.
758+ func formatXMLForLogging (xml string ) string {
759+ // Add newline at start for visual separation from log metadata
760+ return "\n " + xml
761+ }
762+
742763// prepareXMLForLogging redacts sensitive data and formats XML for logging
743764//
744765// This method performs security checks and data sanitization:
745766// 1. Validates XML size to prevent ReDoS attacks (max 1MB)
746767// 2. Checks sensitive element count to prevent DoS (max 1000 elements)
747768// 3. Redacts sensitive data (passwords, secrets, keys, community strings)
748769// 4. Pretty-prints XML if prettyPrintLogs is enabled
770+ // 5. Formats with line prefixes for readability
749771//
750772// Security Note: Size and count limits prevent regex-based DoS attacks during
751773// XML processing and redaction. These limits are conservative to ensure safe
@@ -775,18 +797,25 @@ func (c *Client) prepareXMLForLogging(xml string) string {
775797 // Redact sensitive data first
776798 redacted := c .redactSensitiveData (xml )
777799
778- // Format with xmldot's @pretty modifier (or return as-is if disabled)
800+ // Pretty-print XML if enabled using xmldot's @pretty modifier
779801 if c .prettyPrintLogs {
780- // Use xmldot to parse and pretty-print the XML
781- // Get the root element first, then apply @pretty
782- result := xmldot .Get (redacted , "@pretty" )
783- if result .Exists () {
784- return result .Raw
802+ // Apply @pretty modifier to format the XML with indentation
803+ // Note: xmldot's * selector returns the first child element's content,
804+ // so this will format the response data (e.g., <data> contents) without
805+ // the RPC envelope (<rpc-reply>). This is intentional as the envelope
806+ // is just protocol framing and the actual configuration data is more
807+ // relevant for logging/debugging.
808+ result := xmldot .Get (redacted , "*|@pretty" )
809+ if result .Exists () && result .Raw != "" {
810+ // Format with line prefixes for readability
811+ return formatXMLForLogging (result .Raw )
785812 }
786- // Fallback if @pretty doesn't work - return redacted as-is
813+ // Fallback if pretty printing fails - format raw redacted XML
814+ return formatXMLForLogging (redacted )
787815 }
788816
789- return redacted
817+ // Even without pretty printing, format with line prefixes
818+ return formatXMLForLogging (redacted )
790819}
791820
792821// redactSensitiveData replaces sensitive data in XML with [REDACTED]
@@ -805,20 +834,22 @@ func (c *Client) prepareXMLForLogging(xml string) string {
805834//
806835// Returns the redacted XML string.
807836func (c * Client ) redactSensitiveData (xml string ) string {
808- replacements := [] string {
809- // Elements
810- "< password>[REDACTED]</password>" ,
811- "< secret>[REDACTED]</secret>" ,
812- "< key>[REDACTED]</key>" ,
813- "< community>[REDACTED]</community>" ,
837+ // Custom redaction for elements that handles nested structures
838+ result := xml
839+ result = redactNestedElement ( result , " password" )
840+ result = redactNestedElement ( result , " secret" )
841+ result = redactNestedElement ( result , " key" )
842+ result = redactNestedElement ( result , " community" )
814843
815- // CDATA sections (must match pattern order)
844+ // Apply regex patterns for CDATA, namespaced elements, and attributes
845+ replacements := []string {
846+ // CDATA sections
816847 "<password><![CDATA[[REDACTED]]]></password>" ,
817848 "<secret><![CDATA[[REDACTED]]]></secret>" ,
818849 "<key><![CDATA[[REDACTED]]]></key>" ,
819850 "<community><![CDATA[[REDACTED]]]></community>" ,
820851
821- // Namespace-aware elements (generic replacement works for any namespace)
852+ // Namespace-aware elements
822853 "<ns:password>[REDACTED]</ns:password>" ,
823854 "<ns:secret>[REDACTED]</ns:secret>" ,
824855 "<ns:key>[REDACTED]</ns:key>" ,
@@ -851,9 +882,69 @@ func (c *Client) redactSensitiveData(xml string) string {
851882 `[key='[REDACTED]']` ,
852883 }
853884
854- result := xml
855- for i , pattern := range c .redactionPatterns {
856- result = pattern .ReplaceAllString (result , replacements [i ])
885+ // Skip first 4 patterns (elements) since we handle those with redactNestedElement
886+ for i := 4 ; i < len (c .redactionPatterns ); i ++ {
887+ result = c .redactionPatterns [i ].ReplaceAllString (result , replacements [i - 4 ])
888+ }
889+
890+ return result
891+ }
892+
893+ // redactNestedElement redacts XML elements that may have nested structures with the same tag name.
894+ // Handles Cisco YANG style nesting: <password><password>value</password></password>
895+ // Returns XML with properly balanced tags: <password>[REDACTED]</password>
896+ func redactNestedElement (xml , tagName string ) string {
897+ openTag := "<" + tagName + ">"
898+ closeTag := "</" + tagName + ">"
899+ replacement := openTag + "[REDACTED]" + closeTag
900+
901+ result := ""
902+ pos := 0
903+
904+ for {
905+ // Find next opening tag
906+ start := strings .Index (xml [pos :], openTag )
907+ if start == - 1 {
908+ // No more tags, append remaining
909+ result += xml [pos :]
910+ break
911+ }
912+ start += pos
913+
914+ // Copy text before the tag
915+ result += xml [pos :start ]
916+
917+ // Find matching closing tag (handle nesting)
918+ depth := 1
919+ searchPos := start + len (openTag )
920+
921+ for depth > 0 && searchPos < len (xml ) {
922+ nextOpen := strings .Index (xml [searchPos :], openTag )
923+ nextClose := strings .Index (xml [searchPos :], closeTag )
924+
925+ if nextClose == - 1 {
926+ // Malformed XML, skip this tag
927+ result += xml [start :searchPos ]
928+ pos = searchPos
929+ break
930+ }
931+
932+ if nextOpen != - 1 && nextOpen < nextClose {
933+ // Found nested opening tag
934+ depth ++
935+ searchPos += nextOpen + len (openTag )
936+ } else {
937+ // Found closing tag
938+ depth --
939+ searchPos += nextClose + len (closeTag )
940+ }
941+ }
942+
943+ if depth == 0 {
944+ // Found matching closing tag, replace entire structure
945+ result += replacement
946+ pos = searchPos
947+ }
857948 }
858949
859950 return result
0 commit comments