diff --git a/.github/workflows/fuzz-test.yml b/.github/workflows/fuzz-test.yml new file mode 100644 index 000000000..d1c7060aa --- /dev/null +++ b/.github/workflows/fuzz-test.yml @@ -0,0 +1,117 @@ +name: Fuzz Tests +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + schedule: + # Run extended fuzzing weekly on Sunday at 2am UTC + - cron: "0 2 * * 0" + workflow_dispatch: + inputs: + fuzz_time: + description: "Fuzz duration per test (e.g., 30s, 5m)" + type: string + required: false + default: "1m" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + fuzz-tests: + name: Fuzz Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + go: "1.25" + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: ${{ env.go }} + check-latest: true + + - name: Restore fuzz corpus + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + path: | + ~/.cache/go-build/fuzz + **/testdata/fuzz + key: fuzz-corpus-${{ runner.os }}-${{ hashFiles('**/*_fuzz_test.go') }} + restore-keys: | + fuzz-corpus-${{ runner.os }}- + + - name: Set fuzz duration + id: fuzz-config + env: + INPUT_FUZZ_TIME: ${{ github.event.inputs.fuzz_time }} + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + run: | + if [ -n "$INPUT_FUZZ_TIME" ]; then + echo "FUZZ_TIME=$INPUT_FUZZ_TIME" >> $GITHUB_ENV + elif [ "$EVENT_NAME" = "schedule" ]; then + echo "FUZZ_TIME=2m" >> $GITHUB_ENV + elif [ "$EVENT_NAME" = "push" ] && [ "$REF" = "refs/heads/main" ]; then + echo "FUZZ_TIME=30s" >> $GITHUB_ENV + else + # For PRs, just validate seed corpus (no extended fuzzing) + echo "FUZZ_TIME=0" >> $GITHUB_ENV + fi + + - name: Validate seed corpus (PRs only) + if: env.FUZZ_TIME == '0' + run: | + echo "Validating fuzz test seed corpus..." + go test -run='^Fuzz' -tags=unittest ./common/ + + - name: Run fuzz tests + if: env.FUZZ_TIME != '0' + run: | + echo "Running fuzz tests for $FUZZ_TIME per target..." + + # Run encryption fuzz tests (security-critical) + echo "=== FuzzEncryptDecryptRoundTrip ===" + go test -run='^$' -fuzz=FuzzEncryptDecryptRoundTrip -fuzztime="$FUZZ_TIME" -tags=unittest ./common/ + + echo "=== FuzzDecryptMalformed ===" + go test -run='^$' -fuzz=FuzzDecryptMalformed -fuzztime="$FUZZ_TIME" -tags=unittest ./common/ + + # Run path fuzz tests + echo "=== FuzzNormalizeObjectName ===" + go test -run='^$' -fuzz=FuzzNormalizeObjectName -fuzztime="$FUZZ_TIME" -tags=unittest ./common/ + + echo "=== FuzzJoinUnixFilepath ===" + go test -run='^$' -fuzz=FuzzJoinUnixFilepath -fuzztime="$FUZZ_TIME" -tags=unittest ./common/ + + - name: Save fuzz corpus + if: always() && github.event_name != 'pull_request' + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + with: + path: | + ~/.cache/go-build/fuzz + **/testdata/fuzz + key: fuzz-corpus-${{ runner.os }}-${{ hashFiles('**/*_fuzz_test.go') }}-${{ github.run_id }} + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: fuzz-crash-artifacts-${{ github.sha }} + path: | + **/testdata/fuzz/*/crash-* + **/testdata/fuzz/*/leak-* + if-no-files-found: ignore + retention-days: 30 diff --git a/common/encryption_fuzz_test.go b/common/encryption_fuzz_test.go new file mode 100644 index 000000000..473b9ca96 --- /dev/null +++ b/common/encryption_fuzz_test.go @@ -0,0 +1,97 @@ +/* + Licensed under the MIT License . + + Copyright © 2023-2025 Seagate Technology LLC and/or its Affiliates + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE +*/ + +package common + +import ( + "bytes" + "testing" + + "github.com/awnumar/memguard" +) + +// FuzzEncryptDecryptRoundTrip tests that encrypt followed by decrypt returns original data +func FuzzEncryptDecryptRoundTrip(f *testing.F) { + f.Add([]byte("hello world")) + f.Add([]byte("")) + f.Add([]byte("a")) + f.Add(make([]byte, 1024)) + f.Add([]byte("special chars: !@#$%^&*()")) + f.Add([]byte{0x00, 0x01, 0x02, 0xFF, 0xFE}) + + // Use a fixed, valid base64-encoded 64-byte key + fixedKey := make([]byte, 64) + for i := range fixedKey { + fixedKey[i] = byte(i) + } + + f.Fuzz(func(t *testing.T, data []byte) { + enclave := memguard.NewEnclave(fixedKey) + + encrypted, err := EncryptData(data, enclave) + if err != nil { + return + } + + decrypted, err := DecryptData(encrypted, enclave) + if err != nil { + t.Errorf("decryption failed after successful encryption: %v", err) + return + } + + if !bytes.Equal(data, decrypted) { + t.Errorf("round-trip failed: got %v, want %v", decrypted, data) + } + }) +} + +// FuzzDecryptMalformed tests decryption with malformed ciphertext +func FuzzDecryptMalformed(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{0x00}) + f.Add([]byte{0x00, 0x00}) + f.Add([]byte{0xFF, 0xFF, 0xFF, 0xFF}) + f.Add(make([]byte, 100)) + f.Add([]byte("not encrypted data")) + f.Add([]byte{0x10, 0x00}) // Claims 16 byte salt but no data + f.Add([]byte{0xFF, 0xFF}) // Huge salt length + + // Use a fixed, valid 64-byte key + fixedKey := make([]byte, 64) + for i := range fixedKey { + fixedKey[i] = byte(i) + } + + f.Fuzz(func(t *testing.T, ciphertext []byte) { + enclave := memguard.NewEnclave(fixedKey) + + defer func() { + if r := recover(); r != nil { + t.Errorf("DecryptData panicked on input %v: %v", ciphertext, r) + } + }() + + _, _ = DecryptData(ciphertext, enclave) + }) +} diff --git a/common/path_fuzz_test.go b/common/path_fuzz_test.go new file mode 100644 index 000000000..fb1d33f66 --- /dev/null +++ b/common/path_fuzz_test.go @@ -0,0 +1,99 @@ +/* + Licensed under the MIT License . + + Copyright © 2023-2025 Seagate Technology LLC and/or its Affiliates + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE +*/ + +package common + +import ( + "strings" + "testing" +) + +// FuzzNormalizeObjectName tests that NormalizeObjectName consistently converts +// backslashes to forward slashes without introducing new characters or panicking. +func FuzzNormalizeObjectName(f *testing.F) { + f.Add("normal/path") + f.Add("..\\..\\etc\\passwd") + f.Add("path\\with\\backslash") + f.Add("") + f.Add("/") + f.Add("C:\\Windows\\System32") + f.Add("\\\\network\\share") + f.Add("foo/bar\\baz/qux") + f.Add(strings.Repeat("a", 1000)) + f.Add("path\x00with\x00nulls") + f.Add("特殊字符\\路径") + + f.Fuzz(func(t *testing.T, input string) { + result := NormalizeObjectName(input) + + if strings.Contains(result, "\\") { + t.Errorf("result contains backslash: %q -> %q", input, result) + } + + if len(result) != len(input) { + t.Errorf("result length changed: input=%d, result=%d", len(input), len(result)) + } + + doubleNormalized := NormalizeObjectName(result) + if doubleNormalized != result { + t.Errorf("not idempotent: %q -> %q -> %q", input, result, doubleNormalized) + } + + inputWithoutBackslash := strings.ReplaceAll(input, "\\", "/") + if result != inputWithoutBackslash { + t.Errorf( + "unexpected transformation: expected %q, got %q", + inputWithoutBackslash, + result, + ) + } + }) +} + +// FuzzJoinUnixFilepath tests that JoinUnixFilepath produces valid Unix-style paths +// without backslashes and handles various input combinations safely. +func FuzzJoinUnixFilepath(f *testing.F) { + f.Add("base", "file") + f.Add("/root", "../etc/passwd") + f.Add("", "file") + f.Add("/", "") + f.Add("foo/bar", "baz/qux") + f.Add("C:\\Windows", "System32") + f.Add("path", "..\\..\\etc\\passwd") + f.Add("", "") + f.Add("a", "b") + + f.Fuzz(func(t *testing.T, part1, part2 string) { + result := JoinUnixFilepath(part1, part2) + + if strings.Contains(result, "\\") { + t.Errorf( + "result contains backslash: JoinUnixFilepath(%q, %q) = %q", + part1, + part2, + result, + ) + } + }) +} diff --git a/common/util.go b/common/util.go index f7be1350d..5d1de92ad 100644 --- a/common/util.go +++ b/common/util.go @@ -337,6 +337,15 @@ func DecryptData(cipherData []byte, password *memguard.Enclave) ([]byte, error) return nil, err } + // Validate nonce length before passing to GCM to prevent panic + if len(nonce) != gcm.NonceSize() { + return nil, fmt.Errorf( + "invalid nonce length: got %d, expected %d", + len(nonce), + gcm.NonceSize(), + ) + } + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err @@ -346,21 +355,33 @@ func DecryptData(cipherData []byte, password *memguard.Enclave) ([]byte, error) } func extractSalt(cipherData []byte) ([]byte, error) { - saltLength := binary.LittleEndian.Uint16(cipherData[:uint16Size]) - if len(cipherData) < int(uint16Size+saltLength) { + // Check minimum length for salt length field + if len(cipherData) < int(uint16Size) { + return nil, errors.New("cipher data too short to contain salt length") + } + saltLength := int(binary.LittleEndian.Uint16(cipherData[:uint16Size])) + if len(cipherData) < uint16Size+saltLength { return nil, errors.New("invalid data length") } return cipherData[uint16Size : uint16Size+saltLength], nil } func extractNonceAndCiphertext(cipherData []byte) ([]byte, []byte, error) { - saltLength := binary.LittleEndian.Uint16(cipherData[:uint16Size]) + // Check minimum length for salt length field + if len(cipherData) < int(uint16Size) { + return nil, nil, errors.New("cipher data too short to contain salt length") + } + saltLength := int(binary.LittleEndian.Uint16(cipherData[:uint16Size])) offset := uint16Size + saltLength - nonceLength := binary.LittleEndian.Uint16(cipherData[offset : offset+uint16Size]) + // Check if data is long enough to contain nonce length field + if len(cipherData) < offset+uint16Size { + return nil, nil, errors.New("cipher data too short to contain nonce length") + } + nonceLength := int(binary.LittleEndian.Uint16(cipherData[offset : offset+uint16Size])) offset += uint16Size - if len(cipherData) < int(offset+nonceLength) { + if len(cipherData) < offset+nonceLength { return nil, nil, errors.New("invalid data length") } diff --git a/component/size_tracker/journal_test.go b/component/size_tracker/journal_test.go index cc674200d..3d537c913 100644 --- a/component/size_tracker/journal_test.go +++ b/component/size_tracker/journal_test.go @@ -12,7 +12,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" "github.com/Seagate/cloudfuse/common" @@ -154,9 +153,9 @@ func TestJournal_EpochBumpDiscardsDelta(t *testing.T) { // File should reflect epoch=2 and size 999 (or timestamp changed). content := readFileString(t, dir, jname) require.Contains(t, content, "epoch=2") - require.True( + require.Contains( t, - strings.Contains(content, "size_bytes=999"), + content, "size_bytes=999", fmt.Sprintf("content: %s", content), ) } @@ -222,9 +221,9 @@ func TestJournal_HigherLocalEpochOverwritesFile(t *testing.T) { // File should now reflect epoch=3 and size 150 content := readFileString(t, dir, jname) require.Contains(t, content, "epoch=3") - require.True( + require.Contains( t, - strings.Contains(content, "size_bytes=150"), + content, "size_bytes=150", fmt.Sprintf("expected size_bytes=150 in content: %s", content), ) @@ -251,9 +250,9 @@ func TestJournal_HigherLocalEpochOverwritesFile(t *testing.T) { // File should show epoch=3 and size=175 (overwriting the epoch=2 file) content = readFileString(t, dir, jname) require.Contains(t, content, "epoch=3") - require.True( + require.Contains( t, - strings.Contains(content, "size_bytes=175"), + content, "size_bytes=175", fmt.Sprintf("expected size_bytes=175 in content: %s", content), ) }