Fuzzing
Esta aula aborda o fuzzing em Go, uma técnica automatizada de teste que alimenta funções com entradas aleatórias para encontrar bugs e vulnerabilidades. Você aprenderá a escrever funções de fuzz, executar testes com go test -fuzz, analisar falhas e gerenciar o corpus de entradas.
O fuzzing é uma técnica de teste automatizado que submete um programa a entradas inesperadas ou aleatórias para descobrir bugs, panics ou falhas de segurança. Em Go, o fuzzing é uma funcionalidade nativa desde a versão 1.18, integrada à ferramenta de teste. Diferente dos testes unitários tradicionais, onde você escreve casos fixos, o fuzzing gera dados dinamicamente e explora caminhos de execução que você talvez não tenha previsto.
Nesta aula, vamos explorar como criar funções de fuzz, executar a ferramenta de linha de comando, interpretar os resultados e manter um corpus de entradas para melhorar a cobertura. O fuzzing é especialmente útil para validar parsers, decodificadores, funções que lidam com strings ou bytes, e qualquer código que processe dados externos.
func FuzzXxx
Para criar um teste de fuzz em Go, você define uma função que começa com o prefixo Fuzz e recebe um parâmetro do tipo *testing.F. Essa função deve ser colocada em um arquivo _test.go. A estrutura básica é semelhante a um teste comum, mas em vez de usar t como *testing.T, você usa f como *testing.F.
Dentro da função, você pode usar o método f.Add() para fornecer sementes iniciais (seed corpus) e f.Fuzz() para definir a função alvo que recebe os dados gerados pelo fuzzer. A função alvo deve aceitar um *testing.T como primeiro argumento e depois os parâmetros que serão fuzzados. É importante que a função alvo não retorne valor, apenas use t para reportar falhas.
func FuzzReverse(f *testing.F) {
// Sementes iniciais
f.Add("hello")
f.Add("world")
f.Add("12345")
// Função alvo
f.Fuzz(func(t *testing.T, s string) {
reversed := Reverse(s)
// Verifica se a reversão dupla retorna a original
if Reverse(reversed) != s {
t.Errorf("Reverse(Reverse(%q)) != %q", s, s)
}
})
}No exemplo acima, definimos um fuzz test para uma função Reverse que inverte uma string. O fuzzer vai gerar strings aleatórias e chamar a função alvo. Se a condição falhar, o teste é marcado como falho e o caso é salvo para reprodução.
go test -fuzz
Para executar o fuzzing, você usa o comando go test com a flag -fuzz. Existem duas maneiras principais: fuzzing contínuo (por um período de tempo) e fuzzing por um número específico de execuções. A forma mais comum é:
go test -fuzz=FuzzReverseIsso executará o fuzzing indefinidamente até encontrar uma falha ou ser interrompido (Ctrl+C). Você pode limitar o tempo com -fuzztime:
go test -fuzz=FuzzReverse -fuzztime=30sTambém é possível executar apenas o fuzzing sem executar os testes normais, usando -run=^$ para desabilitar os testes tradicionais:
go test -run=^$ -fuzz=FuzzReverse -fuzztime=10sQuando o fuzzer encontra uma falha, ele gera um arquivo no diretório testdata/fuzz com os dados que causaram o problema. Esse arquivo pode ser usado para reproduzir o bug em um teste normal, simplesmente executando go test novamente.
Encontrando bugs
O principal objetivo do fuzzing é descobrir bugs que podem não ser encontrados por testes tradicionais. Ele faz isso gerando entradas que exploram caminhos extremos, valores limite, strings vazias, caracteres especiais, etc. Ao encontrar uma falha, o Go testa a entrada de forma determinística e grava um caso de teste que falha.
Vamos considerar um exemplo prático. Suponha que temos uma função que calcula a raiz quadrada de um número, mas não valida números negativos:
func Sqrt(x float64) float64 {
return math.Sqrt(x)
}Se aplicarmos fuzzing, o fuzzer pode gerar um número negativo. A função math.Sqrt retorna NaN para negativos, mas nosso código pode não tratar isso. No fuzz test, podemos verificar se o resultado é sempre um número válido:
func FuzzSqrt(f *testing.F) {
f.Add(4.0)
f.Add(0.0)
f.Add(-1.0)
f.Fuzz(func(t *testing.T, x float64) {
result := Sqrt(x)
if x < 0 && !math.IsNaN(result) {
t.Errorf("Sqrt(%v) = %v, expected NaN", x, result)
}
})
}Quando o fuzzer encontrar um caso como -1.0, ele falhará e você saberá que precisa adicionar uma validação. O fuzzing também pode encontrar panics, que são automaticamente tratados como falhas. Por exemplo, se uma função tenta acessar um índice de um slice sem verificar o comprimento, o fuzzer pode gerar um slice vazio e causar um panic.
Corpus
O corpus é a coleção de entradas que o fuzzer usa como base para gerar novas entradas. Inicialmente, o corpus é composto pelas sementes que você adiciona com f.Add(). Conforme o fuzzing evolui, ele adiciona entradas novas ao corpus, especialmente aquelas que aumentam a cobertura de código. Essas entradas são salvas em arquivos no diretório testdata/fuzz e são reutilizadas em execuções futuras.
Gerenciar o corpus é importante para garantir a eficiência e a reprodutibilidade. Você pode adicionar entradas manualmente ao diretório testdata/fuzz para casos específicos que deseja testar. Por exemplo, se você sabe de um bug histórico, pode criar um arquivo de corpus com essa entrada para garantir que ele sempre seja testado.
O formato dos arquivos de corpus é simples: cada arquivo contém o valor da entrada em uma representação textual. Para strings, é o texto entre aspas; para números, a representação decimal; para slices, uma lista de valores. Esses arquivos são gerados automaticamente quando o fuzzer encontra um caso interessante, e você também pode criá-los manualmente.
Para ver o corpus atual, você pode listar os arquivos em testdata/fuzz:
ls testdata/fuzzÉ recomendado versionar esses arquivos, pois eles ajudam na reprodução de bugs e no aumento da cobertura em testes contínuos.
Boas práticas e observações finais
Ao usar fuzzing, siga estas recomendações: (1) comece com sementes que representem casos típicos e extremos; (2) mantenha a função alvo rápida e sem efeitos colaterais, pois ela será executada muitas vezes; (3) não use fuzzing em código que dependa de recursos externos (rede, banco de dados) sem mockar; (4) integre o fuzzing ao seu pipeline de CI com um limite de tempo, para evitar execuções infinitas; (5) revise sempre os arquivos de corpus gerados e adicione manualmente casos críticos.
Referências
- Documentação oficial de Fuzzing em Go
- Anúncio do fuzzing beta no blog Go
- Documentação do pacote testing: tipo F
- Fuzzing para segurança em Go
- Guia de segurança com fuzzing
- Especificação do fuzzing no Go
Exercícios
Crie uma função
FuzzIsPalindromeque testa se uma string é um palíndromo. Use sementes como "", "a", "ab", "aba". A função alvo deve verificar seIsPalindrome(s)retorna o mesmo que comparar a string com sua reversa.✓ Resposta:func FuzzIsPalindrome(f *testing.F) { f.Add("") f.Add("a") f.Add("ab") f.Add("aba") f.Fuzz(func(t *testing.T, s string) { got := IsPalindrome(s) want := s == reverse(s) if got != want { t.Errorf("IsPalindrome(%q) = %v, want %v", s, got, want) } }) } func reverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) }Escreva um fuzz test para uma função
ParseDateque recebe uma string e retorna umtime.Timeou erro. Verifique que, se não houver erro, a data é válida (por exemplo, mês entre 1 e 12). Use sementes como "2024-01-01", "2024-13-01", "abc".✓ Resposta:func FuzzParseDate(f *testing.F) { f.Add("2024-01-01") f.Add("2024-13-01") f.Add("abc") f.Fuzz(func(t *testing.T, s string) { date, err := ParseDate(s) if err == nil { // Verifica se o mês está no intervalo válido if date.Month() < 1 || date.Month() > 12 { t.Errorf("ParseDate(%q) returned invalid month: %v", s, date.Month()) } } }) } func ParseDate(s string) (time.Time, error) { return time.Parse("2006-01-02", s) }Dado o seguinte código com um bug potencial, escreva um fuzz test que encontre o bug. A função
SumSlicesoma todos os elementos de um slice de inteiros. O bug é que ela pode estourar para slices grandes. Verifique se a soma nunca é negativa (assumindo que todos os elementos são não negativos) e que não há panic.func SumSlice(s []int) int { sum := 0 for _, v := range s { sum += v } return sum }✓ Resposta:func FuzzSumSlice(f *testing.F) { f.Add([]int{1, 2, 3}) f.Add([]int{}) f.Add([]int{0}) f.Fuzz(func(t *testing.T, s []int) { sum := SumSlice(s) // Verifica se a soma é não negativa (se todos os elementos são não negativos) for _, v := range s { if v < 0 { return // ignora entradas com negativos } } if sum < 0 { t.Errorf("SumSlice(%v) = %d, negative sum", s, sum) } }) }Explique como você usaria o fuzzing para testar uma função que decodifica JSON em uma struct. Dê um exemplo de código de fuzz test com sementes de JSON válido e inválido.
✓ Resposta:func FuzzUnmarshal(f *testing.F) { f.Add(`{"name":"John","age":30}`) f.Add(`{"name":"Jane"}`) f.Add(`invalid`) f.Fuzz(func(t *testing.T, data string) { var p Person err := json.Unmarshal([]byte(data), &p) if err != nil { return } // Verifica se a idade é não negativa if p.Age < 0 { t.Errorf("Unmarshal(%q) gave negative age: %d", data, p.Age) } }) } type Person struct { Name string `json:"name"` Age int `json:"age"` }Você encontrou um bug durante o fuzzing. Descreva o processo para reproduzi-lo e corrigi-lo, incluindo o uso de arquivos de corpus e testes regulares.
✓ Resposta:Quando o fuzzer encontra uma falha, ele para e grava o caso emtestdata/fuzz/FuzzXxx/<hash>. Para reproduzir, basta executargo test -run=FuzzXxxnovamente, que executará o teste com todos os casos do corpus, incluindo o falho. Depois, você pode depurar o código, corrigir o bug e executar o teste novamente para confirmar. O arquivo de corpus deve ser mantido no repositório para evitar regressões. Você também pode convertê-lo em um teste unitário regular se desejar.