Fuzz testing

1. Introdução ao Fuzz Testing

Fuzz testing (ou fuzzing) é uma técnica de teste automatizado que fornece entradas inválidas, inesperadas ou aleatórias para um programa, com o objetivo de descobrir bugs, vulnerabilidades de segurança e comportamentos inesperados. Diferentemente dos testes unitários tradicionais, onde o desenvolvedor define manualmente casos de teste específicos, o fuzzing gera automaticamente milhares ou milhões de variações de entrada, explorando caminhos de código que dificilmente seriam cobertos manualmente.

A principal diferença entre testes unitários convencionais e fuzz testing está na abordagem: enquanto testes unitários verificam comportamentos esperados com entradas pré-definidas, o fuzzing busca comportamentos inesperados com entradas geradas aleatoriamente. O fuzzer monitora a cobertura de código e tenta maximizá-la, encontrando entradas que exercitam novos caminhos de execução.

Casos de uso comuns incluem:
- Validação de entrada em APIs e parsers
- Processamento de formatos de arquivo (JSON, XML, CSV)
- Funções de serialização/desserialização
- Componentes de segurança e criptografia
- Manipulação de strings e buffers

2. Configuração do Ambiente de Fuzz Testing

O suporte nativo a fuzz testing foi introduzido no Go 1.18. Para utilizá-lo, você precisa:
- Go 1.18 ou superior instalado
- Um arquivo de teste com sufixo _test.go
- Funções fuzz nomeadas no formato FuzzXxx

A estrutura básica de um arquivo de teste com fuzzing segue as mesmas convenções dos testes unitários, mas com uma função especial que recebe *testing.F em vez de *testing.T.

// reverse_test.go
package main

import (
    "testing"
    "unicode/utf8"
)

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

3. Escrevendo sua Primeira Função Fuzz

Vamos implementar um fuzz test para a função Reverse. A sintaxe básica envolve três elementos: a declaração da função, a adição de sementes (seeds) e a implementação do alvo fuzz.

func FuzzReverse(f *testing.F) {
    // Sementes: entradas iniciais para guiar o fuzzer
    testcases := []string{"Hello, world", " ", "12345"}
    for _, tc := range testcases {
        f.Add(tc) // Adiciona cada semente ao corpus
    }

    // Alvo fuzz: função que recebe dados gerados aleatoriamente
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

As sementes (f.Add()) são entradas iniciais que ajudam o fuzzer a começar com dados válidos. O alvo fuzz (f.Fuzz()) recebe um callback que será executado com cada entrada gerada.

4. Executando e Analisando Fuzz Tests

Para executar o fuzz test, use o comando:

go test -fuzz=FuzzReverse -fuzztime=30s

Flags úteis:
- -fuzztime: duração máxima do fuzzing (ex: 30s, 5m)
- -parallel: número de workers paralelos
- -v: saída detalhada

A saída típica mostra:
- Cobertura de código sendo expandida
- Crashes encontrados com entradas minimizadas
- Novas entradas adicionadas ao corpus

Quando um crash é encontrado, o Go salva automaticamente a entrada problemática em testdata/fuzz/FuzzXxx/. Para reexecutar uma entrada específica:

go test -run=FuzzReverse/identificador_do_crash

Exemplo de saída de um crash:

--- FAIL: FuzzReverse (0.20s)
    --- FAIL: FuzzReverse (0.20s)
        reverse_test.go:21: Reverse produced invalid UTF-8 string "\xbf"

5. Estratégias Avançadas de Fuzzing

Trabalhando com múltiplos argumentos e tipos complexos:

func FuzzComplex(f *testing.F) {
    f.Add("hello", 42, []byte("world"))

    f.Fuzz(func(t *testing.T, s string, n int, data []byte) {
        // Testa combinações de diferentes tipos
        result := processData(s, n, data)
        if result == nil {
            t.Skip("Input not valid")
        }
        // Validações adicionais...
    })
}

Combinando fuzz tests com table-driven tests:

func TestReverse(t *testing.T) {
    tests := []struct {
        input    string
        expected string
    }{
        {"hello", "olleh"},
        {"Go", "oG"},
    }
    for _, tt := range tests {
        if got := Reverse(tt.input); got != tt.expected {
            t.Errorf("Reverse(%q) = %q, want %q", tt.input, got, tt.expected)
        }
    }
}

func FuzzReverse(f *testing.F) {
    // Usa os mesmos casos do teste unitário como sementes
    for _, test := range []string{"hello", "Go"} {
        f.Add(test)
    }

    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
    })
}

Uso de t.Skip() e t.Fatalf() para controle de fluxo:

f.Fuzz(func(t *testing.T, data []byte) {
    if len(data) < 3 {
        t.Skip("Input too short")
    }

    result, err := parseData(data)
    if err != nil {
        t.Skip("Expected parse error:", err)
    }

    if result.Value < 0 {
        t.Fatalf("Negative value not allowed: %d", result.Value)
    }
})

6. Depuração de Falhas Encontradas pelo Fuzzer

Quando o fuzzer encontra um crash, você pode depurá-lo de várias formas:

  1. Reexecutando com -v para logs detalhados:
go test -fuzz=FuzzReverse -v
  1. Executando apenas o caso específico:
go test -run=FuzzReverse/6b58f6d5f7e3b0c2
  1. Usando o arquivo salvo em testdata/fuzz/FuzzReverse/:
func TestReverseFromFile(t *testing.T) {
    data, _ := os.ReadFile("testdata/fuzz/FuzzReverse/6b58f6d5f7e3b0c2")
    // Converte e executa o teste
}

Estratégias para corrigir bugs descobertos:

func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

// Versão corrigida após fuzzing
func ReverseFixed(s string) string {
    var builder strings.Builder
    runes := []rune(s)
    for i := len(runes) - 1; i >= 0; i-- {
        builder.WriteRune(runes[i])
    }
    return builder.String()
}

7. Integração com CI/CD e Boas Práticas

Automatizando fuzz tests em pipelines (ex: GitHub Actions):

name: Fuzz Testing
on: [push, pull_request]

jobs:
  fuzz:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/setup-go@v3
      with:
        go-version: '1.21'
    - uses: actions/checkout@v3
    - name: Run fuzz tests
      run: go test -fuzz=FuzzReverse -fuzztime=30s -parallel=4

Limitações importantes:
- Tempo de execução: Fuzzing pode ser lento; defina limites realistas
- Determinismo: Resultados podem variar entre execuções
- Falsos positivos: Nem todo crash é um bug real; analise cada caso

Recomendações:
- Fuzzing contínuo: Execute em CI para regressões
- Fuzzing sob demanda: Para exploração mais profunda
- Manutenção de seeds: Atualize sementes com entradas do mundo real
- Corpus compartilhado: Mantenha testdata/fuzz no repositório

// Exemplo completo com boas práticas
func FuzzUserInput(f *testing.F) {
    // Seeds realistas
    f.Add("admin")
    f.Add("user@example.com")
    f.Add("1234567890")

    f.Fuzz(func(t *testing.T, input string) {
        // Validação básica
        if len(input) > 100 {
            t.Skip("Input too long")
        }

        // Testa sanitização
        sanitized := sanitizeInput(input)
        if sanitized == "" {
            t.Skip("Input sanitized to empty")
        }

        // Verifica se não há injeção
        if strings.Contains(sanitized, "<script>") {
            t.Errorf("XSS vulnerability detected: %q", input)
        }
    })
}

func sanitizeInput(s string) string {
    // Implementação simplificada
    return strings.TrimSpace(s)
}

Referências