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:
- Reexecutando com
-vpara logs detalhados:
go test -fuzz=FuzzReverse -v
- Executando apenas o caso específico:
go test -run=FuzzReverse/6b58f6d5f7e3b0c2
- 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
- Go Fuzzing Official Documentation — Documentação oficial do Go sobre fuzzing, incluindo sintaxe, exemplos e boas práticas.
- Fuzzing in Go 1.18: A Practical Guide — Guia prático detalhado sobre implementação de fuzz tests no Go.
- Go Fuzzing: Finding Bugs Automatically — Tutorial interativo da JetBrains sobre fuzzing com exemplos reais.
- Fuzz Testing in Go: Best Practices — Artigo sobre práticas recomendadas para fuzz testing em Go, incluindo integração CI/CD.
- Go Fuzzing: From Zero to Hero — Guia completo para iniciantes em fuzzing com Go, cobrindo desde conceitos básicos até técnicas avançadas.
- Testing with Go's Native Fuzzing — Artigo da InfoQ explicando o fuzzing nativo do Go e casos de uso em produção.