Skip to content

Commit bfd8288

Browse files
committed
Fix pretty print logging and nested redaction patterns
1 parent 0b89424 commit bfd8288

File tree

3 files changed

+193
-54
lines changed

3 files changed

+193
-54
lines changed

client.go

Lines changed: 115 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ const (
5757

5858
// defaultRedactionPatterns contains regex patterns for redacting sensitive data in logs
5959
var 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.
807836
func (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

logger.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
// MaxLogValueLength limits the length of log values to prevent log injection
1515
// and excessive log file growth. Values longer than this are truncated.
16-
const MaxLogValueLength = 1024
16+
const MaxLogValueLength = 100000
1717

1818
// Logger interface for pluggable logging support
1919
//
@@ -178,14 +178,14 @@ func (l *DefaultLogger) Error(ctx context.Context, msg string, keysAndValues ...
178178
// and limit log size. Handles control characters, ANSI escape sequences,
179179
// Unicode attacks (RTL override, zero-width), and excessive length.
180180
//
181-
// Security Note: Log injection attacks exploit control characters (especially
182-
// newlines) to inject fake log entries or hide malicious activity. This function
183-
// neutralizes such attempts by replacing control characters with safe alternatives.
181+
// Security Note: Newlines (\n) are preserved to support multi-line XML logging
182+
// with pretty printing. Other control characters that could enable log injection
183+
// attacks (carriage return, ANSI escape sequences, etc.) are still sanitized.
184184
//
185-
// Example attack prevented:
185+
// Example sanitization:
186186
//
187-
// Input: "user\n[ERROR] Fake attack message"
188-
// Output: "user .[ERROR].Fake.attack.message"
187+
// Input: "user\r\n\x1b[31mFake\x1b[0m"
188+
// Output: "user\n.31mFake.0m" (newlines preserved, ANSI codes neutralized)
189189
//
190190
// Returns the sanitized string value.
191191
func sanitizeLogValue(val any) string {
@@ -233,8 +233,10 @@ func sanitizeLogValue(val any) string {
233233

234234
// ASCII control characters and ANSI escape sequences
235235
switch r {
236-
case '\n', '\r': // Newline injection
237-
builder.WriteRune(' ')
236+
case '\n': // Preserve newlines for multi-line XML logging
237+
builder.WriteRune('\n')
238+
case '\r': // Carriage return can enable log injection, remove it
239+
// Skip entirely (don't write anything)
238240
case '\t': // Tab injection
239241
builder.WriteRune(' ')
240242
case 0x1B: // ESC - start of ANSI sequence

logger_test.go

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,48 @@ func TestClient_redactSensitiveData(t *testing.T) {
240240
}
241241
}
242242

243+
func TestClient_redactSensitiveData_NestedStructures(t *testing.T) {
244+
client := &Client{
245+
redactionPatterns: defaultRedactionPatterns,
246+
}
247+
248+
tests := []struct {
249+
name string
250+
input string
251+
expected string
252+
}{
253+
{
254+
name: "Nested password (Cisco YANG style)",
255+
input: "<username><name>admin</name><password><password>secret123</password></password></username>",
256+
expected: "<username><name>admin</name><password>[REDACTED]</password></username>",
257+
},
258+
{
259+
name: "Deeply nested password",
260+
input: "<config><password><password><password>secret</password></password></password></config>",
261+
expected: "<config><password>[REDACTED]</password></config>",
262+
},
263+
{
264+
name: "Multiple nested passwords",
265+
input: "<users><user1><password><password>pass1</password></password></user1><user2><password><password>pass2</password></password></user2></users>",
266+
expected: "<users><user1><password>[REDACTED]</password></user1><user2><password>[REDACTED]</password></user2></users>",
267+
},
268+
{
269+
name: "Mixed simple and nested passwords",
270+
input: "<config><simple><password>secret1</password></simple><nested><password><password>secret2</password></password></nested></config>",
271+
expected: "<config><simple><password>[REDACTED]</password></simple><nested><password>[REDACTED]</password></nested></config>",
272+
},
273+
}
274+
275+
for _, tt := range tests {
276+
t.Run(tt.name, func(t *testing.T) {
277+
result := client.redactSensitiveData(tt.input)
278+
if result != tt.expected {
279+
t.Errorf("Expected:\n%s\nGot:\n%s", tt.expected, result)
280+
}
281+
})
282+
}
283+
}
284+
243285
func TestClient_prepareXMLForLogging(t *testing.T) {
244286
client := &Client{
245287
prettyPrintLogs: true,
@@ -286,9 +328,13 @@ func TestClient_prepareXMLForLogging(t *testing.T) {
286328
if !strings.Contains(result, "[REDACTED]") {
287329
t.Errorf("Expected [REDACTED] in output, got: %s", result)
288330
}
289-
// Should be on one line (not pretty printed)
290-
if strings.Contains(result, "\n") {
291-
t.Errorf("Expected no newlines (pretty print disabled), got: %s", result)
331+
// Should start with newline for formatting
332+
if !strings.HasPrefix(result, "\n") {
333+
t.Errorf("Expected leading newline for formatting, got: %s", result)
334+
}
335+
// Should not have additional newlines (not pretty printed - single line XML)
336+
if strings.Count(result, "\n") > 1 {
337+
t.Errorf("Expected only leading newline (not pretty printed), got: %s", result)
292338
}
293339
})
294340
}
@@ -359,25 +405,19 @@ func TestDefaultLogger_LogInjectionPrevention(t *testing.T) {
359405

360406
t.Run("Newline injection", func(t *testing.T) {
361407
buf.Reset()
362-
// Attempt to inject a fake ERROR log line
363-
logger.Info(context.Background(), "Test", "malicious", "value\nFAKE ERROR")
408+
// Newlines are now preserved for multi-line XML logging
409+
logger.Info(context.Background(), "Test", "xml", "value\nFAKE ERROR")
364410

365411
output := buf.String()
366-
lines := strings.Split(output, "\n")
367412

368-
// Should only have 1 log line + final newline = 2 elements
369-
if len(lines) > 2 {
370-
t.Errorf("Log injection detected: %d lines, expected 2", len(lines))
413+
// Newlines are preserved for multi-line logging
414+
if !strings.Contains(output, "\nFAKE ERROR") {
415+
t.Error("Newline should be preserved for multi-line XML logging")
371416
}
372417

373-
// Newline should be replaced with space
374-
if strings.Contains(output, "\nFAKE ERROR") {
375-
t.Error("Newline was not sanitized")
376-
}
377-
378-
// Should contain sanitized version
418+
// Should contain the full content
379419
if !strings.Contains(output, "FAKE ERROR") {
380-
t.Errorf("Expected sanitized content in output, got: %s", output)
420+
t.Errorf("Expected content in output, got: %s", output)
381421
}
382422
})
383423

@@ -443,13 +483,14 @@ func TestDefaultLogger_LongValueTruncation(t *testing.T) {
443483
buf.Reset()
444484

445485
// Create a very long value (> MaxLogValueLength)
446-
longValue := strings.Repeat("A", 2000)
486+
// MaxLogValueLength is 100000, so use 150000 to trigger truncation
487+
longValue := strings.Repeat("A", 150000)
447488
logger.Info(context.Background(), "Test", "key", longValue)
448489

449490
output := buf.String()
450491

451492
// Should be truncated
452-
if strings.Contains(output, strings.Repeat("A", 2000)) {
493+
if strings.Contains(output, strings.Repeat("A", 150000)) {
453494
t.Error("Long value was not truncated")
454495
}
455496

@@ -458,10 +499,15 @@ func TestDefaultLogger_LongValueTruncation(t *testing.T) {
458499
t.Error("Truncation marker not found")
459500
}
460501

461-
// Should contain first part of the value
502+
// Should contain first part of the value (first 100 chars)
462503
if !strings.Contains(output, strings.Repeat("A", 100)) {
463504
t.Error("Expected truncated value to contain first part")
464505
}
506+
507+
// Should NOT contain the end of the long value
508+
if strings.Contains(output, strings.Repeat("A", 140000)) {
509+
t.Error("Expected long tail to be truncated")
510+
}
465511
})
466512

467513
t.Run("Exact limit value", func(t *testing.T) {
@@ -500,12 +546,12 @@ func TestSanitizeLogValue(t *testing.T) {
500546
{
501547
name: "String with newline",
502548
input: "line1\nline2",
503-
expected: "line1 line2",
549+
expected: "line1\nline2", // Newlines preserved for multi-line XML logging
504550
},
505551
{
506552
name: "String with carriage return",
507553
input: "line1\rline2",
508-
expected: "line1 line2",
554+
expected: "line1line2", // Carriage returns removed (security risk)
509555
},
510556
{
511557
name: "String with tab",

0 commit comments

Comments
 (0)