Files
a9d2180dd6 102 redactor newline corruption clean (#1947)
* fix: prevent redactors from corrupting binary files (#102)

Redactors were adding newlines to files without them, corrupting binary
files during support bundle collection (51 bytes → 53 bytes).

Created LineReader to track original newline state and only restore
newlines when they were present in the original file.

- Added pkg/redact/line_reader.go
- Refactored single_line.go, multi_line.go, literal.go
- Added 48 tests, all passing
- Verified: binary files now preserved byte-for-byte

Fixes #102


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 == nil instead of len(line1) == 0 for empty file detection
- Fixes edge case where file containing only '\n' would be dropped
- Addresses bugbot finding about empty line handling


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 != nil instead of len(line1) > 0 in both locations
- Fixes edge case where empty trailing lines would be dropped
- Fix test isolation in literal_test.go (move ResetRedactionList to parent)
- Addresses bugbot findings about empty line handling

* fmt

* chore: update regression baselines from run 20107431959

* adding defense

* fix: propagate non-EOF errors in all early return paths

Ensure non-EOF errors (like buffer overflow) are properly propagated
to caller in both pre-loop early returns. Addresses bugbot finding.

* fix: use unique test names to prevent redaction list pollution

Use t.Name() instead of hardcoded 'test' to ensure each test
has unique redactor name, preventing parallel test interference

---------

Co-authored-by: hedge-sparrow <sparrow@spooky.academy>
2025-12-10 16:55:54 -06:00

652 lines
20 KiB
Go

package redact
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewSingleLineRedactor(t *testing.T) {
tests := []struct {
name string
re string
scan string
inputString string
wantString string
wantRedactions RedactionList
}{
{
name: "copied from default redactors",
re: `(?i)(Pwd *= *)(?P<mask>[^\;]+)(;)`,
inputString: `pwd = abcdef;`,
wantString: "pwd = ***HIDDEN***;", // No trailing newline in input, so none in output
wantRedactions: RedactionList{
ByRedactor: map[string][]Redaction{
"copied from default redactors": []Redaction{
{
RedactorName: "copied from default redactors",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": []Redaction{
{
RedactorName: "copied from default redactors",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "no leading matching group", // this is not the ideal behavior - why are we dropping ungrouped match components?
re: `(?i)Pwd *= *(?P<mask>[^\;]+)(;)`,
inputString: `pwd = abcdef;`,
wantString: "***HIDDEN***;", // No trailing newline in input, so none in output
wantRedactions: RedactionList{
ByRedactor: map[string][]Redaction{
"no leading matching group": []Redaction{
{
RedactorName: "no leading matching group",
CharactersRemoved: 0,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": []Redaction{
{
RedactorName: "no leading matching group",
CharactersRemoved: 0,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "multiple matching literals",
re: `(?i)(Pwd *= *)(?P<mask>[^\;]+)(;)`,
inputString: `pwd = abcdef;abcdef`,
wantString: "pwd = ***HIDDEN***;abcdef", // No trailing newline in input, so none in output
wantRedactions: RedactionList{
ByRedactor: map[string][]Redaction{
"multiple matching literals": []Redaction{
{
RedactorName: "multiple matching literals",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": []Redaction{
{
RedactorName: "multiple matching literals",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact values for environment variables that look like AWS Secret Access Keys",
re: `(?i)("name":"[^\"]*SECRET_?ACCESS_?KEY","value":")(?P<mask>[^\"]*)(")`,
inputString: `{"name":"SECRET_ACCESS_KEY","value":"123"}`,
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{
{
RedactorName: "Redact values for environment variables that look like AWS Secret Access Keys",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": []Redaction{
{
RedactorName: "Redact values for environment variables that look like AWS Secret Access Keys",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact connection strings with username and password",
re: `(?i)(https?|ftp)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
inputString: `http://user:password@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 username and password": []Redaction{
{
RedactorName: "Redact connection strings with username and password",
CharactersRemoved: -12,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": []Redaction{
{
RedactorName: "Redact connection strings with username and password",
CharactersRemoved: -12,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact values for environment variables that look like AWS Secret Access Keys With Scan",
re: `(?i)("name":"[^\"]*SECRET_?ACCESS_?KEY","value":")(?P<mask>[^\"]*)(")`,
scan: `secret_?access_?key`,
inputString: `{"name":"SECRET_ACCESS_KEY","value":"123"}`,
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": {
{
RedactorName: "Redact values for environment variables that look like AWS Secret Access Keys With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": {
{
RedactorName: "Redact values for environment variables that look like AWS Secret Access Keys With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact values for environment variables that look like Access Keys ID With Scan",
re: `(?i)("name":"[^\"]*ACCESS_?KEY_?ID","value":")(?P<mask>[^\"]*)(")`,
scan: `access_?key_?id`,
inputString: `{"name":"ACCESS_KEY_ID","value":"123"}`,
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": {
{
RedactorName: "Redact values for environment variables that look like Access Keys ID With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": {
{
RedactorName: "Redact values for environment variables that look like Access Keys ID With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact values for environment variables that look like Owner Account With Scan",
re: `(?i)("name":"[^\"]*OWNER_?ACCOUNT","value":")(?P<mask>[^\"]*)(")`,
scan: `owner_?account`,
inputString: `{"name":"OWNER_ACCOUNT","value":"123"}`,
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": {
{
RedactorName: "Redact values for environment variables that look like Owner Account With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": {
{
RedactorName: "Redact values for environment variables that look like Owner Account With Scan",
CharactersRemoved: -9,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact 'Data Source' values With Scan",
re: `(?i)(Data Source *= *)(?P<mask>[^\;]+)(;)`,
scan: `data source`,
inputString: `Data Source = abcdef;`,
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": {
{
RedactorName: "Redact 'Data Source' values With Scan",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": {
{
RedactorName: "Redact 'Data Source' values With Scan",
CharactersRemoved: -6,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Redact connection strings With Scan",
re: `(?i)(https?|ftp)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
scan: `https?|ftp`,
inputString: `http://user:password@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": {
{
RedactorName: "Redact connection strings With Scan",
CharactersRemoved: -12,
Line: 1,
File: "testfile",
},
},
},
ByFile: map[string][]Redaction{
"testfile": {
{
RedactorName: "Redact connection strings With Scan",
CharactersRemoved: -12,
Line: 1,
File: "testfile",
},
},
},
},
},
{
name: "Multiple newlines with no match",
re: `abcd`,
inputString: "no match\n\n no match \n\n",
wantString: "no match\n\n no match \n\n",
wantRedactions: RedactionList{
ByRedactor: map[string][]Redaction{},
ByFile: map[string][]Redaction{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := require.New(t)
ResetRedactionList()
reRunner, err := NewSingleLineRedactor(LineRedactor{
regex: tt.re,
scan: tt.scan,
}, MASK_TEXT, "testfile", tt.name, false)
req.NoError(err)
outReader := reRunner.Redact(bytes.NewReader([]byte(tt.inputString)), "")
gotBytes, err := io.ReadAll(outReader)
req.NoError(err)
req.Equal(tt.wantString, string(gotBytes))
actualRedactions := GetRedactionList()
ResetRedactionList()
req.Equal(tt.wantRedactions, actualRedactions)
})
}
}
// 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>.*)`},
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>.*)`},
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<mask>.*)`,
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<mask>[^\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")
}