diff --git a/pkg/collect/collector_test.go b/pkg/collect/collector_test.go index 9362d4b3..9144dbb5 100644 --- a/pkg/collect/collector_test.go +++ b/pkg/collect/collector_test.go @@ -44,8 +44,7 @@ pwd=somethinggoeshere;`, want: map[string]string{ "data/datacollectorname": ` 123 another***HIDDEN***here -pwd=***HIDDEN***; -`, +pwd=***HIDDEN***;`, }, }, { @@ -78,8 +77,7 @@ pwd=somethinggoeshere;`, want: map[string]string{ "data/datacollectorname": `abc 123 another***HIDDEN***here -pwd=***HIDDEN***; -`, +pwd=***HIDDEN***;`, }, }, { @@ -112,8 +110,7 @@ pwd=somethinggoeshere;`, want: map[string]string{ "data/datacollectorname": `abc 123 another line here -pwd=***HIDDEN***; -`, +pwd=***HIDDEN***;`, }, }, { @@ -149,8 +146,7 @@ pwd=somethinggoeshere;`, want: map[string]string{ "data/datacollectorname": `abc 123 another***HIDDEN***here -pwd=***HIDDEN***; -`, +pwd=***HIDDEN***;`, }, }, { @@ -186,8 +182,7 @@ pwd=somethinggoeshere;`, want: map[string]string{ "data/data/collectorname": `***HIDDEN*** ***HIDDEN*** ***HIDDEN*** line here -pwd=***HIDDEN***; -`, +pwd=***HIDDEN***;`, }, }, { @@ -213,8 +208,7 @@ another line here`, }, want: map[string]string{ "data/datacollectorname": `abc 123 -another line here -`, +another line here`, }, }, { @@ -249,8 +243,7 @@ abc`, abc 123 xyz123 -abc -`, +abc`, }, }, { diff --git a/pkg/redact/line_reader.go b/pkg/redact/line_reader.go new file mode 100644 index 00000000..64642c3e --- /dev/null +++ b/pkg/redact/line_reader.go @@ -0,0 +1,104 @@ +package redact + +import ( + "bufio" + "io" + + "github.com/replicatedhq/troubleshoot/pkg/constants" +) + +// LineReader reads lines from an io.Reader while tracking whether each line +// ended with a newline character. This is essential for preserving the exact +// structure of input files during redaction - binary files and text files +// without trailing newlines should not have newlines added to them. +// +// Unlike bufio.Scanner which strips newlines and requires the caller to add +// them back, LineReader explicitly tracks the presence of newlines so callers +// can conditionally restore them only when they were originally present. +type LineReader struct { + reader *bufio.Reader +} + +// NewLineReader creates a new LineReader that reads from the given io.Reader. +// The reader is wrapped in a bufio.Reader for efficient byte-by-byte reading. +func NewLineReader(r io.Reader) *LineReader { + return &LineReader{ + reader: bufio.NewReader(r), + } +} + +// ReadLine reads the next line from the reader and returns: +// - line content (without the newline character if present) +// - whether the line ended with a newline (\n) +// - any error encountered +// +// Return values: +// - (content, true, nil) - line ended with \n, more content may follow +// - (content, false, io.EOF) - last line without \n (file doesn't end with newline) +// - (nil, false, io.EOF) - reached EOF with no content (empty file or end of file) +// - (content, false, error) - encountered a non-EOF error +// +// The function respects constants.SCANNER_MAX_SIZE and returns an error if a single +// line exceeds this limit. This prevents memory exhaustion on files with extremely +// long lines or binary files without newlines that are larger than the limit. +// +// Example usage: +// +// lr := NewLineReader(input) +// for { +// line, hadNewline, err := lr.ReadLine() +// if err == io.EOF && len(line) == 0 { +// break // End of file, no more content +// } +// +// // Process line... +// fmt.Print(string(line)) +// if hadNewline { +// fmt.Print("\n") +// } +// +// if err == io.EOF { +// break // Last line processed +// } +// if err != nil { +// return err +// } +// } +func (lr *LineReader) ReadLine() ([]byte, bool, error) { + // Initialize line as empty slice (not nil) to ensure consistent return values + // Empty lines (just \n) should return []byte{}, not nil + line := []byte{} + + for { + b, err := lr.reader.ReadByte() + + // Handle errors + if err == io.EOF { + if len(line) > 0 { + // Last line without newline - return the content we have + return line, false, io.EOF + } + // Nothing left to read - empty file or end of content + return nil, false, io.EOF + } + if err != nil { + // Non-EOF error encountered + return line, false, err + } + + // Found newline character + if b == '\n' { + // Return the line (may be empty for blank lines) + return line, true, nil + } + + // Accumulate byte into line buffer + line = append(line, b) + + // Check buffer limit to prevent memory exhaustion + // This is especially important for binary files without newlines + if len(line) > constants.SCANNER_MAX_SIZE { + return nil, false, bufio.ErrTooLong + } + } +} diff --git a/pkg/redact/line_reader_test.go b/pkg/redact/line_reader_test.go new file mode 100644 index 00000000..22159e2f --- /dev/null +++ b/pkg/redact/line_reader_test.go @@ -0,0 +1,302 @@ +package redact + +import ( + "bufio" + "bytes" + "io" + "strings" + "testing" + + "github.com/replicatedhq/troubleshoot/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test 1.2 & 1.3: NewLineReader creates instance correctly +func TestNewLineReader(t *testing.T) { + input := strings.NewReader("test") + lr := NewLineReader(input) + + require.NotNil(t, lr) + require.NotNil(t, lr.reader) +} + +// Test 1.8: Empty file → (nil, false, io.EOF) +func TestLineReader_EmptyFile(t *testing.T) { + lr := NewLineReader(strings.NewReader("")) + + line, hadNewline, err := lr.ReadLine() + + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.9: Single line with \n → (content, true, nil) +func TestLineReader_SingleLineWithNewline(t *testing.T) { + lr := NewLineReader(strings.NewReader("hello world\n")) + + line, hadNewline, err := lr.ReadLine() + + assert.Equal(t, []byte("hello world"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second read should return EOF + line, hadNewline, err = lr.ReadLine() + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.10: Single line without \n → (content, false, io.EOF) +func TestLineReader_SingleLineWithoutNewline(t *testing.T) { + lr := NewLineReader(strings.NewReader("hello world")) + + line, hadNewline, err := lr.ReadLine() + + assert.Equal(t, []byte("hello world"), line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.11: Multiple lines with \n → correct for each +func TestLineReader_MultipleLinesWithNewlines(t *testing.T) { + input := "line1\nline2\nline3\n" + lr := NewLineReader(strings.NewReader(input)) + + // First line + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte("line1"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("line2"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Third line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("line3"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // EOF + line, hadNewline, err = lr.ReadLine() + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.12: Last line without \n → (content, false, io.EOF) +func TestLineReader_LastLineWithoutNewline(t *testing.T) { + input := "line1\nline2\nline3" + lr := NewLineReader(strings.NewReader(input)) + + // First line + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte("line1"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("line2"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Third line (no trailing newline) + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("line3"), line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.13: Binary data (no \n) → (all content, false, io.EOF) +func TestLineReader_BinaryData(t *testing.T) { + binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0xFF, 0xFE} + lr := NewLineReader(bytes.NewReader(binaryData)) + + line, hadNewline, err := lr.ReadLine() + + assert.Equal(t, binaryData, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test 1.14: Line exceeding max size → error +func TestLineReader_LineExceedingMaxSize(t *testing.T) { + // Create a line that exceeds SCANNER_MAX_SIZE + largeData := make([]byte, constants.SCANNER_MAX_SIZE+100) + for i := range largeData { + largeData[i] = 'a' + } + + lr := NewLineReader(bytes.NewReader(largeData)) + + line, hadNewline, err := lr.ReadLine() + + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Error(t, err) + assert.ErrorIs(t, err, bufio.ErrTooLong) +} + +// Test 1.15: File with only \n → ([], true, nil) +func TestLineReader_OnlyNewline(t *testing.T) { + lr := NewLineReader(strings.NewReader("\n")) + + line, hadNewline, err := lr.ReadLine() + + assert.Equal(t, []byte{}, line) // Empty line + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second read should return EOF + line, hadNewline, err = lr.ReadLine() + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Additional test: File with empty lines (multiple newlines) +func TestLineReader_EmptyLines(t *testing.T) { + input := "\n\n\n" + lr := NewLineReader(strings.NewReader(input)) + + // First empty line + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte{}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second empty line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte{}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Third empty line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte{}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // EOF + line, hadNewline, err = lr.ReadLine() + assert.Nil(t, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Additional test: Mixed content with and without newlines +func TestLineReader_MixedContent(t *testing.T) { + input := "line1\n\nline3" + lr := NewLineReader(strings.NewReader(input)) + + // First line + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte("line1"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Empty line + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte{}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Last line without newline + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("line3"), line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Additional test: Large but valid file (under max size) +func TestLineReader_LargeValidFile(t *testing.T) { + // Create a line that's large but under the limit + largeData := make([]byte, constants.SCANNER_MAX_SIZE-100) + for i := range largeData { + largeData[i] = 'x' + } + largeData = append(largeData, '\n') + + lr := NewLineReader(bytes.NewReader(largeData)) + + line, hadNewline, err := lr.ReadLine() + + assert.Equal(t, constants.SCANNER_MAX_SIZE-100, len(line)) + assert.True(t, hadNewline) + assert.NoError(t, err) +} + +// Additional test: Binary file with embedded newlines +func TestLineReader_BinaryWithEmbeddedNewlines(t *testing.T) { + binaryData := []byte{0x01, 0x02, '\n', 0x03, 0x04, '\n', 0x05} + lr := NewLineReader(bytes.NewReader(binaryData)) + + // First "line" (up to first \n) + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte{0x01, 0x02}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Second "line" + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte{0x03, 0x04}, line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + // Last "line" without newline + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte{0x05}, line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Test edge case: Very small reads +func TestLineReader_SingleByteReads(t *testing.T) { + input := "a\nb\nc" + lr := NewLineReader(strings.NewReader(input)) + + line, hadNewline, err := lr.ReadLine() + assert.Equal(t, []byte("a"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("b"), line) + assert.True(t, hadNewline) + assert.NoError(t, err) + + line, hadNewline, err = lr.ReadLine() + assert.Equal(t, []byte("c"), line) + assert.False(t, hadNewline) + assert.Equal(t, io.EOF, err) +} + +// Benchmark: LineReader vs bufio.Scanner performance +func BenchmarkLineReader(b *testing.B) { + // Create test data + var buf bytes.Buffer + for i := 0; i < 1000; i++ { + buf.WriteString("This is line number ") + buf.WriteString(string(rune(i))) + buf.WriteString(" with some content\n") + } + data := buf.Bytes() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + lr := NewLineReader(bytes.NewReader(data)) + for { + _, _, err := lr.ReadLine() + if err == io.EOF { + break + } + } + } +} diff --git a/pkg/redact/literal.go b/pkg/redact/literal.go index bff66130..7be5bd67 100644 --- a/pkg/redact/literal.go +++ b/pkg/redact/literal.go @@ -3,6 +3,7 @@ package redact import ( "bufio" "bytes" + "errors" "fmt" "io" @@ -25,6 +26,12 @@ func literalString(match []byte, path, name string) Redactor { } } +// Redact processes the input reader line-by-line, replacing literal string matches. +// Unlike the previous implementation using bufio.Scanner, this now uses LineReader +// to preserve the exact newline structure of the input file. Lines that originally +// ended with \n will have \n added back, while lines without \n (like the last line +// of a file without a trailing newline, or binary files) will not have \n added. +// This ensures binary files and text files without trailing newlines are not corrupted. func (r literalRedactor) Redact(input io.Reader, path string) io.Reader { out, writer := io.Pipe() @@ -34,7 +41,8 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader { if err == nil || err == io.EOF { writer.Close() } else { - if err == bufio.ErrTooLong { + // Check if error is about line exceeding maximum size + if errors.Is(err, bufio.ErrTooLong) { s := fmt.Sprintf("Error redacting %q. A line in the file exceeded %d MB max length", path, constants.SCANNER_MAX_SIZE/1024/1024) klog.V(2).Info(s) } else { @@ -44,17 +52,24 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader { } }() - buf := make([]byte, constants.BUF_INIT_SIZE) - scanner := bufio.NewScanner(input) - scanner.Buffer(buf, constants.SCANNER_MAX_SIZE) - + // Use LineReader instead of bufio.Scanner to track newline presence + lineReader := NewLineReader(input) + tokenizer := GetGlobalTokenizer() lineNum := 0 - for scanner.Scan() { - lineNum++ - line := scanner.Bytes() + for { + line, hadNewline, readErr := lineReader.ReadLine() + + // Handle EOF with no content - we're done + if readErr == io.EOF && len(line) == 0 { + break + } + + // We have content to process + lineNum++ + + // Perform literal string replacement var clean []byte - tokenizer := GetGlobalTokenizer() if tokenizer.IsEnabled() { // For literal redaction, we tokenize the matched value matchStr := string(r.match) @@ -66,12 +81,20 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader { clean = bytes.ReplaceAll(line, r.match, maskTextBytes) } - // Append newline since scanner strips it - err = writeBytes(writer, clean, NEW_LINE) + // Write the line (redacted or original) + err = writeBytes(writer, clean) if err != nil { return } + // Only add newline if original line had one + if hadNewline { + err = writeBytes(writer, NEW_LINE) + if err != nil { + return + } + } + // Track redaction if content changed if !bytes.Equal(clean, line) { addRedaction(Redaction{ RedactorName: r.redactName, @@ -81,9 +104,16 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader { IsDefaultRedactor: r.isDefault, }) } - } - if scanErr := scanner.Err(); scanErr != nil { - err = scanErr + + // Check if we hit EOF after processing this line + if readErr == io.EOF { + break + } + // Check for non-EOF errors + if readErr != nil { + err = readErr + return + } } }() return out diff --git a/pkg/redact/literal_test.go b/pkg/redact/literal_test.go new file mode 100644 index 00000000..f9fb3c17 --- /dev/null +++ b/pkg/redact/literal_test.go @@ -0,0 +1,386 @@ +package redact + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test basic literal redaction functionality +func TestLiteralRedactor_BasicRedaction(t *testing.T) { + tests := []struct { + name string + match string + inputString string + wantString string + }{ + { + name: "Simple literal match", + match: "secret123", + inputString: "password=secret123", + wantString: "password=***HIDDEN***", // No trailing newline in input + }, + { + name: "Multiple occurrences", + match: "secret", + inputString: "secret is secret here secret", + wantString: "***HIDDEN*** is ***HIDDEN*** here ***HIDDEN***", + }, + { + name: "No match", + match: "xyz", + inputString: "no match here", + wantString: "no match here", + }, + { + name: "With trailing newline", + match: "secret", + inputString: "secret\n", + wantString: "***HIDDEN***\n", + }, + { + name: "Multiline with newlines", + match: "secret", + inputString: "line1 secret\nline2 secret\n", + wantString: "line1 ***HIDDEN***\nline2 ***HIDDEN***\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + redactor := literalString([]byte(tt.match), "testfile", tt.name) + + out := redactor.Redact(bytes.NewReader([]byte(tt.inputString)), "") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, tt.wantString, string(result)) + }) + } +} + +// Test 4.12: Binary file → unchanged +func TestLiteralRedactor_BinaryFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Binary content with no newlines and no match + binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0xAB, 0xCD} + + redactor := literalString([]byte("notfound"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file should be unchanged") +} + +// Test: Binary file with every single byte value (0x00 -> 0xFF) +func TestLiteralRedactor_AllSingleByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with every possible byte value + binaryData := make([]byte, 256) + for i := 0; i < 256; i++ { + binaryData[i] = byte(i) + } + + redactor := literalString([]byte("notfound"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all byte values should be unchanged") + require.Len(t, result, 256, "Should preserve all 256 bytes") +} + +// Test: Binary file with every two-byte combination (0x00+0x00 -> 0xFF+0xFF) +func TestLiteralRedactor_AllTwoByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with all 65536 two-byte combinations (128KB) + binaryData := make([]byte, 256*256*2) + pos := 0 + for i := 0; i < 256; i++ { + for j := 0; j < 256; j++ { + binaryData[pos] = byte(i) + binaryData[pos+1] = byte(j) + pos += 2 + } + } + + redactor := literalString([]byte("notfound"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all two-byte combinations should be unchanged") + require.Len(t, result, 256*256*2, "Should preserve all 131072 bytes") +} + +// Test 4.12 (variant): Binary file with literal match → redacted, no extra newlines +func TestLiteralRedactor_BinaryFileWithMatch(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Binary content with a literal match (0xFF 0xFE sequence) + binaryData := []byte{0x01, 0x02, 0xFF, 0xFE, 0x03, 0x04} + + redactor := literalString([]byte{0xFF, 0xFE}, "testfile", t.Name()) + + // We need to mock maskTextBytes for this test to work predictably + // For now, test that no newlines are added + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.NotEqual(t, binaryData, result, "Binary should be redacted") + require.NotContains(t, result, []byte{0xFF, 0xFE}, "Match should be replaced") + // Most importantly: no trailing newline added to binary file + require.NotEqual(t, byte('\n'), result[len(result)-1], "Should not add trailing newline") +} + +// Test 4.13: Text with trailing \n → preserved +func TestLiteralRedactor_TextWithTrailingNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "hello world\n" + + redactor := literalString([]byte("xyz"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "hello world\n", string(result), "Trailing newline should be preserved") +} + +// Test 4.14: Text without trailing \n → preserved +func TestLiteralRedactor_TextWithoutTrailingNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "hello world" + + redactor := literalString([]byte("xyz"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "hello world", string(result), "No newline should be added") +} + +// Test 4.15: Empty file → unchanged +func TestLiteralRedactor_EmptyFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "", string(result), "Empty file should remain empty") +} + +// Test 4.16: Literal match and replacement works +func TestLiteralRedactor_LiteralMatch(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "password=secret123" + + redactor := literalString([]byte("secret123"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "password=***HIDDEN***", string(result)) +} + +// Test 4.17: Multiple occurrences replaced +func TestLiteralRedactor_MultipleOccurrences(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "secret here and secret there and secret everywhere" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "***HIDDEN*** here and ***HIDDEN*** there and ***HIDDEN*** everywhere", string(result)) +} + +// Test 4.17 (variant): Multiple occurrences across lines +func TestLiteralRedactor_MultipleOccurrencesMultiline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "line1 secret\nline2 secret\nline3 secret\n" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := "line1 ***HIDDEN***\nline2 ***HIDDEN***\nline3 ***HIDDEN***\n" + require.Equal(t, expected, string(result)) +} + +// Test 4.18: Tokenization works +func TestLiteralRedactor_Tokenization(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Enable tokenization for this test + EnableTokenization() + defer DisableTokenization() + + input := "password=secret123" + + redactor := literalString([]byte("secret123"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + // Result should contain a token, not the original or ***HIDDEN*** + require.NotContains(t, string(result), "secret123") + require.NotContains(t, string(result), "***HIDDEN***") + require.Contains(t, string(result), "password=") +} + +// Test 4.19: Redaction count accurate +func TestLiteralRedactor_RedactionCount(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "secret here\nsecret there" + + // Use unique redactor name and filename to avoid pollution from parallel tests + uniqueFile := "TestLiteralRedactor_RedactionCount_file" + uniqueRedactor := "TestLiteralRedactor_RedactionCount_redactor" + + redactor := literalString([]byte("secret"), uniqueFile, uniqueRedactor) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "") + _, err := io.ReadAll(out) + + require.NoError(t, err) + + redactions := GetRedactionList() + // Two lines, each with one match = 2 redaction events + require.Len(t, redactions.ByRedactor[uniqueRedactor], 2, "Should record 2 redactions (one per line)") + require.Len(t, redactions.ByFile[uniqueFile], 2, "Should record 2 redactions for file") +} + +// Test 4.20: Backward compatibility - existing behavior preserved for text with newlines +func TestLiteralRedactor_BackwardCompatibility(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "line1 secret\nline2 secret\nline3\n" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := "line1 ***HIDDEN***\nline2 ***HIDDEN***\nline3\n" + require.Equal(t, expected, string(result), "Behavior for text with newlines should be unchanged") +} + +// Test 4.20 (variant): Literal match on last line without \n +func TestLiteralRedactor_LastLineWithoutNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "line1\nline2 secret" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := "line1\nline2 ***HIDDEN***" + require.Equal(t, expected, string(result), "Should not add newline to last line") +} + +// Additional test: Empty line handling +func TestLiteralRedactor_EmptyLines(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "\n\n\n" + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "\n\n\n", string(result), "Empty lines should be preserved") +} + +// Additional test: Large file with many matches +func TestLiteralRedactor_LargeFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create large file with many occurrences + var input strings.Builder + for i := 0; i < 1000; i++ { + input.WriteString("line ") + input.WriteString("secret") + input.WriteString(" here\n") + } + + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(strings.NewReader(input.String()), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.NotContains(t, string(result), "secret", "All secrets should be redacted") + require.Contains(t, string(result), "***HIDDEN***") +} + +// Additional test: Partial match should not be replaced +func TestLiteralRedactor_PartialMatchNotReplaced(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "secret secretive secrets" + + // Should only replace exact literal "secret", not "secretive" or "secrets" + redactor := literalString([]byte("secret"), "testfile", t.Name()) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "***HIDDEN*** ***HIDDEN***ive ***HIDDEN***s", string(result)) +} diff --git a/pkg/redact/multi_line.go b/pkg/redact/multi_line.go index b90014fb..400567db 100644 --- a/pkg/redact/multi_line.go +++ b/pkg/redact/multi_line.go @@ -1,12 +1,18 @@ package redact import ( - "bufio" "bytes" "io" "regexp" ) +// lineState represents a line and whether it ended with a newline character. +// This is used by MultiLineRedactor to track newline state for line pairs. +type lineState struct { + content []byte + hadNewline bool +} + type MultiLineRedactor struct { scan *regexp.Regexp re1 *regexp.Regexp @@ -39,6 +45,16 @@ func NewMultiLineRedactor(re1 LineRedactor, re2 string, maskText, path, name str return &MultiLineRedactor{scan: scanCompiled, re1: compiled1, re2: compiled2, maskText: maskText, filePath: path, redactName: name, isDefault: isDefault}, nil } +// Redact processes the input reader in pairs of lines, applying redaction patterns. +// Unlike the previous implementation using bufio.Reader with readLine(), this now +// uses LineReader to preserve the exact newline structure of the input file. +// +// The MultiLineRedactor works by: +// 1. Reading pairs of lines (line1, line2) +// 2. If line1 matches the selector pattern (re1), redact line2 using re2 +// 3. Write both lines with their original newline structure preserved +// +// This ensures binary files and text files without trailing newlines are not corrupted. func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader { out, writer := io.Pipe() go func() { @@ -48,51 +64,74 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader { }() tokenizer := GetGlobalTokenizer() + lineReader := NewLineReader(input) - reader := bufio.NewReader(input) - line1, line2, err := getNextTwoLines(reader, nil) - if err != nil { - // this will print 2 blank lines for empty input... - // Append newlines since scanner strips them - err = writeBytes(writer, line1, NEW_LINE, line2, NEW_LINE) - if err != nil { - return + // Try to read first two lines + line1, nl1, line2, nl2, readErr := getNextTwoLines(lineReader, nil) + + // Handle case where we can't read 2 lines (empty file or single line) + // Note: We check line1 == nil (not len(line1) == 0) because: + // - nil means truly empty file with no content + // - []byte{} (len==0) means an empty line that had a newline (e.g., "\n") + if readErr != nil && line1 == nil { + // Empty file - nothing to write + // Propagate non-EOF errors (EOF is expected for empty files) + if readErr != io.EOF { + err = readErr } - return } + if readErr != nil { + // Only 1 line available (or empty line with newline) - write it and exit + // FIX: This is the bug fix - only add newline if original had one + // Also handles empty lines (line1 == []byte{} with nl1 == true) + err = writeLine(writer, line1, nl1) + if err != nil { + return + } + // Propagate non-EOF errors (EOF is expected for single-line files) + if readErr != io.EOF { + err = readErr + } + return + } + + // Process line pairs flushLastLine := false lineNum := 1 - for err == nil { + + for readErr == nil { lineNum++ // the first line that can be redacted is line 2 - // is scan is not nil, then check if line1 matches scan by lowercasing it + // Pre-filter: if scan is not nil, check if line1 matches scan by lowercasing it if r.scan != nil { lowerLine1 := bytes.ToLower(line1) if !r.scan.Match(lowerLine1) { - // Append newline since scanner strips it - err = writeBytes(writer, line1, NEW_LINE) + // No match - write line1 and advance + err = writeLine(writer, line1, nl1) if err != nil { return } - line1, line2, err = getNextTwoLines(reader, &line2) + line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2}) flushLastLine = true continue } } - // If line1 matches re1, then transform line2 using re2 + // Check if line1 matches the selector pattern (re1) if !r.re1.Match(line1) { - // Append newline since scanner strips it - err = writeBytes(writer, line1, NEW_LINE) + // No match - write line1 and advance + err = writeLine(writer, line1, nl1) if err != nil { return } - line1, line2, err = getNextTwoLines(reader, &line2) + line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2}) flushLastLine = true continue } + + // line1 matched selector - redact line2 flushLastLine = false var clean []byte if tokenizer.IsEnabled() { @@ -105,13 +144,17 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader { clean = r.re2.ReplaceAll(line2, substStr) } - // Append newlines since scanner strips them - err = writeBytes(writer, line1, NEW_LINE, clean, NEW_LINE) + // Write line1 (selector line) and line2 (redacted line) + err = writeLine(writer, line1, nl1) + if err != nil { + return + } + err = writeLine(writer, clean, nl2) if err != nil { return } - // if clean is not equal to line2, a redaction was performed + // Track redaction if content changed if !bytes.Equal(clean, line2) { addRedaction(Redaction{ RedactorName: r.redactName, @@ -122,42 +165,92 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader { }) } - line1, line2, err = getNextTwoLines(reader, nil) + // Get next pair + line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, nil) } - if flushLastLine { - // Append newline since scanner strip it - err = writeBytes(writer, line1, NEW_LINE) + // After loop exits (readErr != nil), check if we have an unwritten line1 + // This happens in two cases: + // 1. flushLastLine=true: line1 was advanced but not written (scan/re1 didn't match) + // 2. line1 != nil: we read line1 but couldn't get line2 (unpaired line at end) + // Note: We check line1 != nil (not len(line1) > 0) to handle empty lines ([]byte{}) + if flushLastLine || line1 != nil { + err = writeLine(writer, line1, nl1) if err != nil { return } } + + // Propagate non-EOF read errors to the caller + // EOF is expected (end of file) and not an error condition + // Note: readErr is always non-nil here (loop exited), but we only propagate non-EOF errors + if readErr != io.EOF { + err = readErr + } }() return out } -func getNextTwoLines(reader *bufio.Reader, curLine2 *[]byte) (line1 []byte, line2 []byte, err error) { - line2 = []byte{} - +// getNextTwoLines reads the next pair of lines from the LineReader. +// It returns the content and newline state for both lines. +// +// If curLine2 is provided, it's used as line1 (optimization for advancing through file). +// Otherwise, both lines are read fresh from the reader. +// +// Returns: +// - line1, hadNewline1: First line content and newline state +// - line2, hadNewline2: Second line content and newline state +// - err: Error only if we couldn't read line1, or if line2 read failed with non-EOF error +// +// Note: If line2 returns (content, false, io.EOF), we treat this as SUCCESS because +// we got the content. The EOF just means it didn't have a trailing newline. +func getNextTwoLines(lr *LineReader, curLine2 *lineState) ( + line1 []byte, hadNewline1 bool, + line2 []byte, hadNewline2 bool, + err error, +) { if curLine2 == nil { - line1, err = readLine(reader) + // Read both lines fresh + line1, hadNewline1, err = lr.ReadLine() if err != nil { return } - line2, err = readLine(reader) + line2, hadNewline2, err = lr.ReadLine() + // If we got line2 content but hit EOF, that's OK - it just means no trailing newline + if err == io.EOF && len(line2) > 0 { + err = nil // Clear the error - we successfully read both lines + } return } - line1 = *curLine2 - line2, err = readLine(reader) - if err != nil { - return - } + // Use cached line2 as new line1 (optimization) + line1 = curLine2.content + hadNewline1 = curLine2.hadNewline + // Read new line2 + line2, hadNewline2, err = lr.ReadLine() + // If we got line2 content but hit EOF, that's OK - it just means no trailing newline + if err == io.EOF && len(line2) > 0 { + err = nil // Clear the error - we successfully read both lines + } return } +// writeLine writes a line to the writer, optionally adding a newline if hadNewline is true. +// This helper reduces code duplication in the Redact function. +func writeLine(w io.Writer, line []byte, hadNewline bool) error { + if err := writeBytes(w, line); err != nil { + return err + } + if hadNewline { + if err := writeBytes(w, NEW_LINE); err != nil { + return err + } + } + return nil +} + // writeBytes writes all byte slices to the writer // in the order they are passed in the variadic argument func writeBytes(w io.Writer, bs ...[]byte) error { diff --git a/pkg/redact/multi_line_test.go b/pkg/redact/multi_line_test.go index 289452f9..a43434d7 100644 --- a/pkg/redact/multi_line_test.go +++ b/pkg/redact/multi_line_test.go @@ -28,8 +28,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"name": "secret_access_key" "value": "dfeadsfsdfe"`, wantString: `"name": "secret_access_key" -"value": "***HIDDEN***" -`, +"value": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Redact multiline with AWS secret id", @@ -40,8 +39,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"name": "ACCESS_KEY_ID" "value": "dfeadsfsdfe"`, wantString: `"name": "ACCESS_KEY_ID" -"value": "***HIDDEN***" -`, +"value": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Redact multiline with OSD", @@ -52,8 +50,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"entity": "osd.1abcdef" "key": "Gjt8s0WkfPtxZUo7gI8a0awbQGHgzuprdaedfb=="`, wantString: `"entity": "osd.1abcdef" -"key": "***HIDDEN***" -`, +"key": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Redact multiline with AWS secret access key and scan regex", @@ -65,8 +62,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"name": "secret_access_key" "value": "dfeadsfsdfe"`, wantString: `"name": "secret_access_key" -"value": "***HIDDEN***" -`, +"value": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Redact multiline with AWS secret id and scan regex", @@ -78,8 +74,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"name": "ACCESS_KEY_ID" "value": "dfeadsfsdfe"`, wantString: `"name": "ACCESS_KEY_ID" -"value": "***HIDDEN***" -`, +"value": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Redact multiline with OSD and scan regex", @@ -91,8 +86,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { inputString: `"entity": "osd.1abcdef" "key": "Gjt8s0WkfPtxZUo7gI8a0awbQGHgzuprdaedfb=="`, wantString: `"entity": "osd.1abcdef" -"key": "***HIDDEN***" -`, +"key": "***HIDDEN***"`, // No trailing newline in input, so none in output }, { name: "Multiple newlines with no match", @@ -102,7 +96,7 @@ func Test_NewMultiLineRedactor(t *testing.T) { }, redactor: `(?i)("value": *")(?P.*[^\"]*)(")`, inputString: "no match\n\n no match \n\n", - wantString: "no match\n\n no match \n\n", + wantString: "no match\n\n no match \n\n", // Input has trailing newline, should be preserved }, } for _, tt := range tests { @@ -158,3 +152,281 @@ func Test_writeBytes(t *testing.T) { }) } } + +// Test 3.16: Binary file (no newlines) → unchanged +func TestMultiLineRedactor_BinaryFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Binary content with no newlines - the bug that caused 2 extra bytes + binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0xAB, 0xCD} + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file should be unchanged (no extra newlines)") +} + +// Test: Binary file with every single byte value (0x00 -> 0xFF) +func TestMultiLineRedactor_AllSingleByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with every possible byte value + binaryData := make([]byte, 256) + for i := 0; i < 256; i++ { + binaryData[i] = byte(i) + } + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all byte values should be unchanged") + require.Len(t, result, 256, "Should preserve all 256 bytes") +} + +// Test: Binary file with every two-byte combination (0x00+0x00 -> 0xFF+0xFF) +func TestMultiLineRedactor_AllTwoByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with all 65,536 two-byte combinations (128KB) + binaryData := make([]byte, 256*256*2) + pos := 0 + for i := 0; i < 256; i++ { + for j := 0; j < 256; j++ { + binaryData[pos] = byte(i) + binaryData[pos+1] = byte(j) + pos += 2 + } + } + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all two-byte combinations should be unchanged") + require.Len(t, result, 256*256*2, "Should preserve all 131,072 bytes (64k combinations)") +} + +// Test 3.17: Single line with \n → unchanged +func TestMultiLineRedactor_SingleLineWithNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "single line\n" + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "single line\n", string(result)) +} + +// Test 3.18: Single line without \n → unchanged +func TestMultiLineRedactor_SingleLineWithoutNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "single line" + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "single line", string(result), "No newline should be added") +} + +// Test 3.19: Empty file → unchanged +func TestMultiLineRedactor_EmptyFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "" + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `"name":`}, + `"value":`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "", string(result), "Empty file should remain empty") +} + +// Test 3.20: Two lines, matches selector → line2 redacted +func TestMultiLineRedactor_TwoLinesMatch(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := `"name": "PASSWORD" +"value": "secret123"` + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `(?i)"name": *"PASSWORD"`}, + `(?i)("value": *")(?P[^"]*)(")`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := `"name": "PASSWORD" +"value": "***HIDDEN***"` + require.Equal(t, expected, string(result)) +} + +// Test 3.21: Two lines, no selector match → unchanged +func TestMultiLineRedactor_TwoLinesNoMatch(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := `"name": "USERNAME" +"value": "admin"` + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `(?i)"name": *"PASSWORD"`}, + `(?i)("value": *")(?P[^"]*)(")`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := `"name": "USERNAME" +"value": "admin"` + require.Equal(t, expected, string(result)) +} + +// Test 3.22: Multiple line pairs → correct redactions +func TestMultiLineRedactor_MultiplePairs(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := `"name": "PASSWORD" +"value": "secret1" +"name": "TOKEN" +"value": "secret2" +"name": "USERNAME" +"value": "admin" +` + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `(?i)"name": *"(PASSWORD|TOKEN)"`}, + `(?i)("value": *")(?P[^"]*)(")`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := `"name": "PASSWORD" +"value": "***HIDDEN***" +"name": "TOKEN" +"value": "***HIDDEN***" +"name": "USERNAME" +"value": "admin" +` + require.Equal(t, expected, string(result)) +} + +// Test 3.23: Three lines (pair + unpaired) +func TestMultiLineRedactor_ThreeLines(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := `"name": "PASSWORD" +"value": "secret" +unpaired line` + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `(?i)"name": *"PASSWORD"`}, + `(?i)("value": *")(?P[^"]*)(")`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := `"name": "PASSWORD" +"value": "***HIDDEN***" +unpaired line` + require.Equal(t, expected, string(result)) +} + +// Test 3.24: Large file with selector matches +func TestMultiLineRedactor_LargeFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + var input strings.Builder + for i := 0; i < 1000; i++ { + input.WriteString(`"name": "PASSWORD"` + "\n") + input.WriteString(`"value": "secret"` + "\n") + } + + redactor, err := NewMultiLineRedactor( + LineRedactor{regex: `(?i)"name": *"PASSWORD"`}, + `(?i)("value": *")(?P[^"]*)(")`, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(strings.NewReader(input.String()), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + + // Verify all secrets were redacted + require.NotContains(t, string(result), `"value": "secret"`) + require.Contains(t, string(result), `"value": "***HIDDEN***"`) +} diff --git a/pkg/redact/redact.go b/pkg/redact/redact.go index 4242b0eb..b0e16ac2 100644 --- a/pkg/redact/redact.go +++ b/pkg/redact/redact.go @@ -104,6 +104,10 @@ func GetRedactionList() RedactionList { } func ResetRedactionList() { + // Wait for all pending redaction goroutines to complete before resetting + // This prevents race conditions where goroutines write to the map after reset + pendingRedactions.Wait() + redactionListMut.Lock() defer redactionListMut.Unlock() allRedactions = RedactionList{ diff --git a/pkg/redact/redact_test.go b/pkg/redact/redact_test.go index 24eedc80..d1c153df 100644 --- a/pkg/redact/redact_test.go +++ b/pkg/redact/redact_test.go @@ -1724,6 +1724,7 @@ func Test_Redactors(t *testing.T) { t.Run("test default redactors", func(t *testing.T) { req := require.New(t) + ResetRedactionList() // Ensure clean state before test redactors, err := getRedactors("testpath") req.NoError(err) diff --git a/pkg/redact/single_line.go b/pkg/redact/single_line.go index 21c652cc..50b76306 100644 --- a/pkg/redact/single_line.go +++ b/pkg/redact/single_line.go @@ -3,6 +3,7 @@ package redact import ( "bufio" "bytes" + "errors" "fmt" "io" "regexp" @@ -39,6 +40,13 @@ func NewSingleLineRedactor(re LineRedactor, maskText, path, name string, isDefau return &SingleLineRedactor{scan: scanCompiled, re: compiled, maskText: maskText, filePath: path, redactName: name, isDefault: isDefault}, nil } +// Redact processes the input reader line-by-line, applying redaction patterns. + +// Unlike the previous implementation using bufio.Scanner, this now uses LineReader +// to preserve the exact newline structure of the input file. Lines that originally +// ended with \n will have \n added back, while lines without \n (like the last line +// of a file without a trailing newline, or binary files) will not have \n added. +// This ensures binary files and text files without trailing newlines are not corrupted. func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader { out, writer := io.Pipe() @@ -48,7 +56,8 @@ func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader { if err == nil || err == io.EOF { writer.Close() } else { - if err == bufio.ErrTooLong { + // Check if error is about line exceeding maximum size + if errors.Is(err, bufio.ErrTooLong) { s := fmt.Sprintf("Error redacting %q. A line in the file exceeded %d MB max length", path, constants.SCANNER_MAX_SIZE/1024/1024) klog.V(2).Info(s) } else { @@ -58,68 +67,89 @@ func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader { } }() - buf := make([]byte, constants.BUF_INIT_SIZE) - scanner := bufio.NewScanner(input) - scanner.Buffer(buf, constants.SCANNER_MAX_SIZE) - + // Use LineReader instead of bufio.Scanner to track newline presence + lineReader := NewLineReader(input) tokenizer := GetGlobalTokenizer() lineNum := 0 - for scanner.Scan() { - lineNum++ - line := scanner.Bytes() - // is scan is not nil, then check if line matches scan by lowercasing it + for { + line, hadNewline, readErr := lineReader.ReadLine() + + // Handle EOF with no content - we're done + if readErr == io.EOF && len(line) == 0 { + break + } + + // We have content to process + lineNum++ + + // Determine if we should redact this line + shouldRedact := true + + // Pre-filter: if scan is not nil, check if line matches scan by lowercasing it if r.scan != nil { lowerLine := bytes.ToLower(line) if !r.scan.Match(lowerLine) { - // Append newline since scanner strips it - err = writeBytes(writer, line, NEW_LINE) - if err != nil { - return - } - continue + shouldRedact = false } } - // if scan matches, but re does not, do not redact - if !r.re.Match(line) { - // Append newline since scanner strips it - err = writeBytes(writer, line, NEW_LINE) - if err != nil { - return - } - continue + // Check if line matches the main redaction pattern + if shouldRedact && !r.re.Match(line) { + shouldRedact = false } - var clean []byte - if tokenizer.IsEnabled() { - // Use tokenized replacement - context comes from the redactor name which often indicates the secret type - context := r.redactName - clean = getTokenizedReplacementPatternWithPath(r.re, line, context, r.filePath) + // Process the line (redact or pass through) + var outputLine []byte + if shouldRedact { + // Line matches - perform redaction + if tokenizer.IsEnabled() { + // Use tokenized replacement - context comes from the redactor name + context := r.redactName + outputLine = getTokenizedReplacementPatternWithPath(r.re, line, context, r.filePath) + } else { + // Use original masking behavior + substStr := []byte(getReplacementPattern(r.re, r.maskText)) + outputLine = r.re.ReplaceAll(line, substStr) + } + + // Track redaction if content changed + if !bytes.Equal(outputLine, line) { + addRedaction(Redaction{ + RedactorName: r.redactName, + CharactersRemoved: len(line) - len(outputLine), + Line: lineNum, + File: r.filePath, + IsDefaultRedactor: r.isDefault, + }) + } } else { - // Use original masking behavior - substStr := []byte(getReplacementPattern(r.re, r.maskText)) - clean = r.re.ReplaceAll(line, substStr) + // No match - use original line + outputLine = line } - // Append newline since scanner strips it - err = writeBytes(writer, clean, NEW_LINE) + + // Write the line + err = writeBytes(writer, outputLine) if err != nil { return } - - // if clean is not equal to line, a redaction was performed - if !bytes.Equal(clean, line) { - addRedaction(Redaction{ - RedactorName: r.redactName, - CharactersRemoved: len(line) - len(clean), - Line: lineNum, - File: r.filePath, - IsDefaultRedactor: r.isDefault, - }) + // Only add newline if original line had one + if hadNewline { + err = writeBytes(writer, NEW_LINE) + if err != nil { + return + } + } + + // Check if we hit EOF after processing this line + if readErr == io.EOF { + break + } + // Check for non-EOF errors + if readErr != nil { + err = readErr + return } - } - if scanErr := scanner.Err(); scanErr != nil { - err = scanErr } }() return out diff --git a/pkg/redact/single_line_test.go b/pkg/redact/single_line_test.go index b743b465..29765271 100644 --- a/pkg/redact/single_line_test.go +++ b/pkg/redact/single_line_test.go @@ -21,7 +21,7 @@ func TestNewSingleLineRedactor(t *testing.T) { name: "copied from default redactors", re: `(?i)(Pwd *= *)(?P[^\;]+)(;)`, inputString: `pwd = abcdef;`, - wantString: "pwd = ***HIDDEN***;\n", + wantString: "pwd = ***HIDDEN***;", // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "copied from default redactors": []Redaction{ @@ -49,7 +49,7 @@ func TestNewSingleLineRedactor(t *testing.T) { name: "no leading matching group", // this is not the ideal behavior - why are we dropping ungrouped match components? re: `(?i)Pwd *= *(?P[^\;]+)(;)`, inputString: `pwd = abcdef;`, - wantString: "***HIDDEN***;\n", + wantString: "***HIDDEN***;", // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "no leading matching group": []Redaction{ @@ -77,7 +77,7 @@ func TestNewSingleLineRedactor(t *testing.T) { name: "multiple matching literals", re: `(?i)(Pwd *= *)(?P[^\;]+)(;)`, inputString: `pwd = abcdef;abcdef`, - wantString: "pwd = ***HIDDEN***;abcdef\n", + wantString: "pwd = ***HIDDEN***;abcdef", // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "multiple matching literals": []Redaction{ @@ -105,8 +105,7 @@ func TestNewSingleLineRedactor(t *testing.T) { name: "Redact values for environment variables that look like AWS Secret Access Keys", re: `(?i)("name":"[^\"]*SECRET_?ACCESS_?KEY","value":")(?P[^\"]*)(")`, inputString: `{"name":"SECRET_ACCESS_KEY","value":"123"}`, - wantString: `{"name":"SECRET_ACCESS_KEY","value":"***HIDDEN***"} -`, + wantString: `{"name":"SECRET_ACCESS_KEY","value":"***HIDDEN***"}`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact values for environment variables that look like AWS Secret Access Keys": []Redaction{ @@ -134,7 +133,7 @@ func TestNewSingleLineRedactor(t *testing.T) { name: "Redact connection strings with username and password", re: `(?i)(https?|ftp)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, inputString: `http://user:password@host:8888`, - wantString: "http://***HIDDEN***:***HIDDEN***@host:8888\n", + wantString: "http://***HIDDEN***:***HIDDEN***@host:8888", // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact connection strings with username and password": []Redaction{ @@ -163,8 +162,7 @@ func TestNewSingleLineRedactor(t *testing.T) { re: `(?i)("name":"[^\"]*SECRET_?ACCESS_?KEY","value":")(?P[^\"]*)(")`, scan: `secret_?access_?key`, inputString: `{"name":"SECRET_ACCESS_KEY","value":"123"}`, - wantString: `{"name":"SECRET_ACCESS_KEY","value":"***HIDDEN***"} -`, + wantString: `{"name":"SECRET_ACCESS_KEY","value":"***HIDDEN***"}`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact values for environment variables that look like AWS Secret Access Keys With Scan": { @@ -193,8 +191,7 @@ func TestNewSingleLineRedactor(t *testing.T) { re: `(?i)("name":"[^\"]*ACCESS_?KEY_?ID","value":")(?P[^\"]*)(")`, scan: `access_?key_?id`, inputString: `{"name":"ACCESS_KEY_ID","value":"123"}`, - wantString: `{"name":"ACCESS_KEY_ID","value":"***HIDDEN***"} -`, + wantString: `{"name":"ACCESS_KEY_ID","value":"***HIDDEN***"}`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact values for environment variables that look like Access Keys ID With Scan": { @@ -223,8 +220,7 @@ func TestNewSingleLineRedactor(t *testing.T) { re: `(?i)("name":"[^\"]*OWNER_?ACCOUNT","value":")(?P[^\"]*)(")`, scan: `owner_?account`, inputString: `{"name":"OWNER_ACCOUNT","value":"123"}`, - wantString: `{"name":"OWNER_ACCOUNT","value":"***HIDDEN***"} -`, + wantString: `{"name":"OWNER_ACCOUNT","value":"***HIDDEN***"}`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact values for environment variables that look like Owner Account With Scan": { @@ -253,8 +249,7 @@ func TestNewSingleLineRedactor(t *testing.T) { re: `(?i)(Data Source *= *)(?P[^\;]+)(;)`, scan: `data source`, inputString: `Data Source = abcdef;`, - wantString: `Data Source = ***HIDDEN***; -`, + wantString: `Data Source = ***HIDDEN***;`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact 'Data Source' values With Scan": { @@ -283,8 +278,7 @@ func TestNewSingleLineRedactor(t *testing.T) { re: `(?i)(https?|ftp)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, scan: `https?|ftp`, inputString: `http://user:password@host:8888;`, - wantString: `http://***HIDDEN***:***HIDDEN***@host:8888; -`, + wantString: `http://***HIDDEN***:***HIDDEN***@host:8888;`, // No trailing newline in input, so none in output wantRedactions: RedactionList{ ByRedactor: map[string][]Redaction{ "Redact connection strings With Scan": { @@ -340,3 +334,318 @@ func TestNewSingleLineRedactor(t *testing.T) { }) } } + +// Test 2.15: Binary file (no newlines) → unchanged +func TestSingleLineRedactor_BinaryFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Binary content with no newlines + binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0xAB, 0xCD} + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "password"}, // Pattern that won't match binary + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file should be unchanged") +} + +// Test: Binary file with every single byte value (0x00 -> 0xFF) +func TestSingleLineRedactor_AllSingleByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with every possible byte value + binaryData := make([]byte, 256) + for i := 0; i < 256; i++ { + binaryData[i] = byte(i) + } + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `secret`}, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all byte values should be unchanged") + require.Len(t, result, 256, "Should preserve all 256 bytes") +} + +// Test: Binary file with every two-byte combination (0x00+0x00 -> 0xFF+0xFF) +func TestSingleLineRedactor_AllTwoByteValues(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create binary data with all 65,536 two-byte combinations (128KB) + binaryData := make([]byte, 256*256*2) + pos := 0 + for i := 0; i < 256; i++ { + for j := 0; j < 256; j++ { + binaryData[pos] = byte(i) + binaryData[pos+1] = byte(j) + pos += 2 + } + } + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `secret`}, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(binaryData), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, binaryData, result, "Binary file with all two-byte combinations should be unchanged") + require.Len(t, result, 256*256*2, "Should preserve all 131,072 bytes (64k combinations)") +} + +// Test 2.16: Text file with \n → preserved +func TestSingleLineRedactor_TextFileWithNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "hello world\n" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "xyz"}, // No match + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "hello world\n", string(result), "Trailing newline should be preserved") +} + +// Test 2.17: Text file without \n → preserved +func TestSingleLineRedactor_TextFileWithoutNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "hello world" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "xyz"}, // No match + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "hello world", string(result), "No newline should be added") +} + +// Test 2.18: Empty file → unchanged +func TestSingleLineRedactor_EmptyFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "password"}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "", string(result), "Empty file should remain empty") +} + +// Test 2.19: Single line with secret → redacted correctly +func TestSingleLineRedactor_SingleLineWithSecret(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "password=secret123" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `(?i)(password=)(?P.*)`}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "password=***HIDDEN***", string(result)) +} + +// Test 2.20: Multiple lines with secrets → all redacted +func TestSingleLineRedactor_MultipleLinesWithSecrets(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "password=secret1\npassword=secret2\npassword=secret3\n" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `(?i)(password=)(?P.*)`}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := "password=***HIDDEN***\npassword=***HIDDEN***\npassword=***HIDDEN***\n" + require.Equal(t, expected, string(result)) +} + +// Test 2.21: Scan pattern filters correctly +func TestSingleLineRedactor_ScanPatternFilters(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "password=secret\nusername=admin\n" + + redactor, err := NewSingleLineRedactor( + LineRedactor{ + regex: `(?i)(password=)(?P.*)`, + scan: `password`, // Only process lines containing "password" + }, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + expected := "password=***HIDDEN***\nusername=admin\n" + require.Equal(t, expected, string(result)) +} + +// Test 2.22: File with only one newline \n → one newline out +func TestSingleLineRedactor_OnlyNewline(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + input := "\n" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "password"}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, "\n", string(result)) +} + +// Test 2.23: Mixed binary/text content +func TestSingleLineRedactor_MixedContent(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Binary data with embedded newline + input := []byte{0x01, 0x02, '\n', 0x03, 0x04} + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "password"}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(input), "test.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, input, result, "Mixed content should be preserved") +} + +// Test: Windows CRLF (\r\n) line endings preserved +func TestSingleLineRedactor_WindowsLineEndings(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Windows-style line endings with no secrets + input := "line1\r\nline2\r\nline3\r\n" + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `secret`}, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + // Windows line endings should be preserved exactly + require.Equal(t, "line1\r\nline2\r\nline3\r\n", string(result), + "Windows CRLF line endings should be preserved, not converted to LF") +} + +// Test: Windows CRLF with redaction +func TestSingleLineRedactor_WindowsLineEndingsWithRedaction(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Windows-style line endings with a secret + // Note: LineReader splits on \n, so line content includes \r + input := "password=secret123\r\nusername=admin\r\n" + + // Use a regex that doesn't capture the \r + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: `(password=)(?P[^\r\n]+)`}, + MASK_TEXT, "testfile", t.Name(), false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt") + result, err := io.ReadAll(out) + + require.NoError(t, err) + // \r\n should be preserved: regex doesn't capture \r, so it stays in output + expected := "password=***HIDDEN***\r\nusername=admin\r\n" + require.Equal(t, expected, string(result), + "Windows CRLF should be preserved - \\r not captured by regex, \\n added by LineReader") +} + +// Test 2.24: Large binary file (1MB, no newlines) → preserved +func TestSingleLineRedactor_LargeBinaryFile(t *testing.T) { + ResetRedactionList() + defer ResetRedactionList() + + // Create 1MB of binary data + largeData := make([]byte, 1024*1024) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + redactor, err := NewSingleLineRedactor( + LineRedactor{regex: "password"}, + MASK_TEXT, "testfile", "test", false, + ) + require.NoError(t, err) + + out := redactor.Redact(bytes.NewReader(largeData), "large.bin") + result, err := io.ReadAll(out) + + require.NoError(t, err) + require.Equal(t, largeData, result, "Large binary file should be unchanged") +} diff --git a/test/baselines/metadata.json b/test/baselines/metadata.json index 08919d0c..02e0dd69 100644 --- a/test/baselines/metadata.json +++ b/test/baselines/metadata.json @@ -1,7 +1,7 @@ { - "updated_at": "2025-11-27T05:43:03Z", - "git_sha": "280a582f4f5d1dcb242dc9e4441ba1797b0209c7", - "workflow_run_id": "19726392590", + "updated_at": "2025-12-10T17:29:30Z", + "git_sha": "7d73318d1ea65a72cfe2ffa7ea52940cf4b98f18", + "workflow_run_id": "20107431959", "k8s_version": "v1.31.2-k3s1", - "updated_by": "Ethan Mosbaugh " + "updated_by": "hedge-sparrow " } diff --git a/test/baselines/preflight-v1beta2/baseline.tar.gz b/test/baselines/preflight-v1beta2/baseline.tar.gz index 35d26bf5..b3824fef 100644 Binary files a/test/baselines/preflight-v1beta2/baseline.tar.gz and b/test/baselines/preflight-v1beta2/baseline.tar.gz differ diff --git a/test/baselines/preflight-v1beta3/baseline.tar.gz b/test/baselines/preflight-v1beta3/baseline.tar.gz index 50bbd5fa..06220ad3 100644 Binary files a/test/baselines/preflight-v1beta3/baseline.tar.gz and b/test/baselines/preflight-v1beta3/baseline.tar.gz differ diff --git a/test/baselines/supportbundle/baseline.tar.gz b/test/baselines/supportbundle/baseline.tar.gz index 95e841bc..4595fa5d 100644 Binary files a/test/baselines/supportbundle/baseline.tar.gz and b/test/baselines/supportbundle/baseline.tar.gz differ