Le fuzzing Go natif génère automatiquement des milliers d'entrées imprévues pour casser votre code, sans dépendance externe. Depuis Go 1.18, go test -fuzz fait partie de la chaîne d'outils standard. Dans ce guide, vous écrivez un test de fuzzing sur un encodeur maison, vous laissez le moteur trouver un crash réel en moins d'une seconde, puis vous corrigez le bug et vérifiez la correction en re-fuzzant.
Ce guide s'adresse aux développeurs Go intermédiaires à avancés qui écrivent des parseurs, décodeurs ou toute fonction traitant des entrées non fiables. Vous saurez rédiger un FuzzXxx, lire la sortie du moteur, rejouer un crash en régression et connaître les angles morts du fuzzing natif. Tout le code et toutes les sorties de ce guide viennent d'un lab réel sous Go 1.26.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire un test de fuzzing avec
testing.Fet l'invariant de round-trip. - Lancer
go test -fuzzet interpréter sa sortie (baseline, crash, minimisation). - Comprendre le corpus : graines,
testdata/fuzz/, cache généré. - Rejouer un crash minimisé comme test de régression.
- Corriger un bug débusqué par le fuzzer, puis re-fuzzer pour confirmer.
- Connaître les limites du fuzzing natif et comment les compléter.
Prérequis
Section intitulée « Prérequis »Le fuzzing est intégré au langage depuis Go 1.18, il n'y a donc rien à installer. Ce guide a été exécuté avec Go 1.26. Vérifiez votre version, la couverture instrumentée n'est disponible que sur AMD64 et ARM64 :
go versionLa sortie doit afficher une version supérieure ou égale à 1.18 :
go version go1.26.5 linux/amd64Une aisance minimale avec go test et les tests unitaires Go suffit. Si le concept même du fuzzing (familles, corpus, sanitizers) est encore flou, lisez d'abord la page dédiée avant de revenir ici.
Le code à tester
Section intitulée « Le code à tester »Pour rendre l'exercice concret, on part d'un encodeur run-length (RLE) volontairement naïf. L'idée du RLE est simple : compresser les répétitions. La chaîne "aaabb" devient "a3b2" (trois a, deux b). Le fichier rle.go contient un défaut réel que le fuzzer va débusquer.
// Package rle implemente un encodage run-length naif, avec un bug latent.// Format : chaque caractere est suivi de son nombre de repetitions en decimal.package rle
import ( "strconv" "strings")
// Encode compresse une chaine en run-length encoding.func Encode(s string) string { if s == "" { return "" } var b strings.Builder runes := []rune(s) prev := runes[0] count := 1 for _, c := range runes[1:] { if c == prev { count++ continue } b.WriteRune(prev) b.WriteString(strconv.Itoa(count)) prev = c count = 1 } b.WriteRune(prev) b.WriteString(strconv.Itoa(count)) return b.String()}
// Decode decompresse une chaine RLE. Il lit un caractere puis TOUS les chiffres// consecutifs comme compteur.func Decode(s string) string { var b strings.Builder runes := []rune(s) i := 0 for i < len(runes) { char := runes[i] i++ digits := strings.Builder{} for i < len(runes) && runes[i] >= '0' && runes[i] <= '9' { digits.WriteRune(runes[i]) i++ } count := 1 if digits.Len() > 0 { count, _ = strconv.Atoi(digits.String()) } for j := 0; j < count; j++ { b.WriteRune(char) } } return b.String()}À l'oeil nu, ce code paraît correct : Encode("aaabb") donne bien "a3b2" et Decode("a3b2") reconstruit "aaabb". C'est exactement le genre de faux sentiment de sécurité que le fuzzing démonte. Deux défauts se cachent ici : la conversion []rune d'une part, et l'ambiguïté du format avec les chiffres d'autre part. Vous allez les découvrir dans l'ordre où le moteur les remonte.
Écrire un test de fuzzing
Section intitulée « Écrire un test de fuzzing »Un test de fuzzing Go n'a rien d'ésotérique : c'est une fonction FuzzXxx placée dans un fichier _test.go, qui reçoit un *testing.F. La bonne stratégie ici est le test de propriété (property-based) : plutôt que de deviner des cas précis, on affirme un invariant qui doit tenir pour toute entrée. Pour un encodeur, l'invariant naturel est le round-trip : Decode(Encode(s)) doit toujours redonner s.
Voici rle_test.go. Notez les deux briques : f.Add fournit des graines (des cas de départ valides qui amorcent le moteur) et f.Fuzz reçoit la fonction pilotée par les entrées générées.
package rle
import "testing"
// FuzzRoundTrip verifie l'invariant Decode(Encode(s)) == s pour toute chaine s.func FuzzRoundTrip(f *testing.F) { // Graines : des cas valides qui amorcent le moteur. f.Add("aaabb") f.Add("abc") f.Add("")
f.Fuzz(func(t *testing.T, s string) { encoded := Encode(s) decoded := Decode(encoded) if decoded != s { t.Errorf("round-trip casse : %q -> %q -> %q", s, encoded, decoded) } })}Trois contraintes structurantes encadrent ce test. D'abord, un seul f.Fuzz par fonction de fuzzing. Ensuite, les arguments de f.Add doivent avoir exactement les mêmes types, dans le même ordre, que ceux de la fonction passée à f.Fuzz : ici un string unique. Enfin, seuls les types scalaires sont acceptés (string, []byte, les entiers signés et non signés, rune, byte, float32/64, bool). Pas de struct, pas de slice composite, pas de map ni de pointeur : le moteur ne sait muter que des octets bruts.
Lancer go test -fuzz
Section intitulée « Lancer go test -fuzz »Le fuzzing se déclenche avec le flag -fuzz, qui prend une expression régulière ne devant matcher qu'une seule fonction. Sans ce flag, go test exécute les graines comme de simples tests unitaires et s'arrête. On borne la durée avec -fuzztime, indispensable pour un run reproductible :
go test -fuzz=FuzzRoundTrip -fuzztime=20sLa sortie réelle du lab tient en quelques lignes. Le moteur mesure d'abord une couverture de référence (baseline) sur les graines, puis lance ses workers. Le crash tombe immédiatement :
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completedfuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 16 workersfuzz: minimizing 38-byte failing input filefuzz: elapsed: 0s, minimizing--- FAIL: FuzzRoundTrip (0.03s) --- FAIL: FuzzRoundTrip (0.00s) rle_test.go:17: round-trip casse : "\xe8" -> "�1" -> "�"
Failing input written to testdata/fuzz/FuzzRoundTrip/5845f96f56664c01 To re-run: go test -run=FuzzRoundTrip/5845f96f56664c01FAILexit status 1FAIL example.com/rle 0.038sTrois informations comptent ici. Le moteur a trouvé une entrée fautive en moins de trois centièmes de seconde. Il l'a minimisée : parti d'un fichier de 38 octets, il l'a réduit à un seul octet significatif, "\xe8". Et il a écrit l'entrée dans testdata/fuzz/FuzzRoundTrip/ avec la commande exacte pour la rejouer. Le hash exact varie d'un run à l'autre (une autre exécution est tombée sur "\xae"), mais la classe de bug est toujours la même.
Le corpus et les régressions
Section intitulée « Le corpus et les régressions »Le fuzzing Go manipule trois emplacements de corpus qu'il ne faut pas confondre. Le seed corpus (graines) combine les appels f.Add du code et le contenu versionné de testdata/fuzz/<NomDuFuzz>/. Le corpus généré, lui, vit dans $GOCACHE/fuzz et n'existe que pendant un run avec -fuzz : c'est le réservoir d'entrées intéressantes que le moteur garde parce qu'elles augmentent la couverture. Ce cache n'est pas censé être versionné.
Le point clé pour la qualité de votre code : dès qu'un crash est trouvé, Go écrit l'entrée minimisée dans testdata/fuzz/, sous Git. Elle rejoint le seed corpus et devient un test de régression rejoué à chaque go test ordinaire, même sans -fuzz. Le fichier généré est du texte lisible :
go test fuzz v1string("\xae")La première ligne est un en-tête de version, la suivante porte la valeur typée. Ce format explique aussi comment injecter un cas connu à la main : il suffit de déposer un tel fichier dans testdata/fuzz/<NomDuFuzz>/. L'outil file2fuzz (dans golang.org/x/tools) convertit un fichier binaire arbitraire vers ce format.
Rejouer un crash
Section intitulée « Rejouer un crash »Une fois le reproducteur écrit, vous n'avez pas besoin de re-fuzzer pour retravailler le bug. Le flag -run cible le cas précis via son hash, comme n'importe quel sous-test :
go test -run="FuzzRoundTrip/0ae9e7fb4a758a1c"L'échec se rejoue à l'identique, instantanément et sans workers :
--- FAIL: FuzzRoundTrip (0.00s) --- FAIL: FuzzRoundTrip/0ae9e7fb4a758a1c (0.00s) rle_test.go:17: round-trip casse : "\xae" -> "�1" -> "�"FAILexit status 1FAIL example.com/rle 0.002sCe cycle rejeu rapide, correction, re-vérification est la vraie boucle de travail. On garde le fuzzing long pour chercher de nouveaux cas, et on utilise -run pour déboguer un cas déjà connu sans attendre.
Comprendre et corriger
Section intitulée « Comprendre et corriger »Le message "\xae" -> "�1" -> "�" cache le premier bug, et il est spécifique à Go. L'octet 0xae seul n'est pas de l'UTF-8 valide. Or Encode fait []rune(s) : cette conversion remplace tout octet invalide par le caractère de remplacement U+FFFD (affiché �). L'information de l'octet d'origine est perdue dès la conversion, donc Decode ne peut jamais reconstruire "\xae". Le round-trip casse avant même que la logique RLE n'entre en jeu.
Le fuzzer a remonté ce cas en premier parce qu'il est le plus court : un seul octet. Mais le second bug, celui du format, est bien présent lui aussi. Il apparaît dès qu'une entrée contient un chiffre. Vérifiez-le directement :
Encode("a1") // -> "a111"Decode("a111") // -> "a" repete 111 foisLa sortie réelle du lab confirme l'ampleur du dégât :
"a1" -> "a111" -> "aaaaaaaaaaa..." (len 111)"x9" -> "x191" -> "xxxxxxxxxxx..." (len 191)La cause est structurelle : Encode("a1") produit "a1" + "1" (le a suivi de son compteur 1, puis le 1 suivi de son compteur 1), soit "a111". Au décodage, Decode lit le a puis avale tous les chiffres consécutifs comme compteur : il lit 111 et répète a cent-onze fois. Le format mélange donnée et compteur sans séparateur : il est ambigu, donc irréparable en l'état.
La correction doit traiter les deux causes d'un coup. On abandonne string/[]rune pour travailler sur []byte (aucun octet n'est réinterprété), et on rend le format non ambigu en émettant des paires (compteur, octet) où le compteur est un octet brut jamais confondu avec la donnée. Voici rle_fixed.go :
package rle
// EncodeFixed travaille sur []byte (aucun octet reinterprete) et emet des// paires (compteur, octet) : le compteur est un octet brut, jamais melange// avec la donnee. Les runs de plus de 255 octets sont scindes.func EncodeFixed(data []byte) []byte { var out []byte i := 0 for i < len(data) { b := data[i] count := 1 for i+count < len(data) && data[i+count] == b && count < 255 { count++ } out = append(out, byte(count), b) i += count } return out}
// DecodeFixed inverse EncodeFixed : il lit des paires (compteur, octet).func DecodeFixed(data []byte) []byte { var out []byte for i := 0; i+1 < len(data); i += 2 { count := int(data[i]) b := data[i+1] for j := 0; j < count; j++ { out = append(out, b) } } return out}On écrit ensuite un nouveau test de fuzzing sur cette version, avec des graines qui incluent explicitement les cas qui cassaient l'ancienne implémentation ("a1", "\xae", "\xe8") :
package rle
import ( "bytes" "testing")
func FuzzRoundTripFixed(f *testing.F) { f.Add([]byte("aaabb")) f.Add([]byte("abc")) f.Add([]byte("")) f.Add([]byte("a1")) f.Add([]byte("\xae")) f.Add([]byte("\xe8"))
f.Fuzz(func(t *testing.T, data []byte) { encoded := EncodeFixed(data) decoded := DecodeFixed(encoded) if !bytes.Equal(decoded, data) { t.Errorf("round-trip casse : %q -> %q -> %q", data, encoded, decoded) } })}On re-fuzze la version corrigée pendant 20 secondes. Cette fois le moteur ne trouve aucun échec et se termine sur un PASS net :
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 16 workersfuzz: elapsed: 3s, execs: 1379419 (459472/sec), new interesting: 12 (total: 18)fuzz: elapsed: 12s, execs: 5692415 (471533/sec), new interesting: 12 (total: 18)fuzz: elapsed: 20s, execs: 9491673 (457307/sec), new interesting: 12 (total: 18)PASSok example.com/rle 20.107sNeuf millions et demi d'exécutions, aucun crash : la ligne new interesting se stabilise, signe que le moteur ne trouve plus de nouveaux chemins intéressants. Ce n'est pas une preuve d'absence de bug, mais une confiance largement renforcée que l'invariant tient désormais.
Fuzzer en CI
Section intitulée « Fuzzer en CI »Le fuzzing en intégration continue obéit à une règle simple : borner le temps. Une campagne de plusieurs heures a sa place en continu (via OSS-Fuzz), pas sur une pull request. En CI, on lance un run court qui vérifie surtout que les régressions du corpus versionné passent et qu'aucun crash évident ne surgit :
go test -fuzz=FuzzRoundTrip -fuzztime=30s ./...Deux réflexes rendent cette étape utile. D'abord, versionner testdata/fuzz/ pour que chaque go test normal rejoue les régressions connues, même dans les jobs sans -fuzz. Ensuite, garder le fuzz borné court (30 secondes suffisent pour attraper les régressions rapides), et déléguer le fuzzing long à une infrastructure dédiée. Le contrôle Fuzzing d'OpenSSF Scorecard valorise justement la présence de ce harnais dans votre dépôt.
Les limites du fuzzing natif
Section intitulée « Les limites du fuzzing natif »Le fuzzing intégré à Go est remarquablement accessible, mais il ne voit pas tout. C'est un moteur coverage-guided sur types scalaires, et cela dessine des angles morts précis qu'il faut connaître pour ne pas se croire couvert à tort. Le tableau ci-dessous résume ce qu'il ne détecte pas par lui-même.
| Angle mort | Pourquoi | Comment compléter |
|---|---|---|
| Data races | Le moteur juge la sortie, pas la concurrence | Lancer avec -race |
| Integer overflows | Un dépassement silencieux ne panique pas | Assertions, revue de code |
| Goroutine leaks | Aucune notion de fuite de goroutine | Détecteurs dédiés en test |
| Timeouts / boucles infinies | Une exécution figée coupe à 1 s sans forcément échouer | Bornes explicites, budget de temps |
| Comparaisons complexes | Le moteur peine à franchir un == sur un long littéral | Dictionnaires, graines ciblées |
| Types composites | Ni struct, ni map, ni structure-aware | Encoder/décoder manuellement en []byte |
La parade la plus rentable est de coupler -fuzz et -race : le second instrumente les accès concurrents que le premier ignore. Pour aller au-delà (fuites de goroutines, invariants métier riches), des outils tiers existent, comme gosentry, qui ajoutent des détecteurs par-dessus le moteur natif. Le message à retenir : le fuzzing natif est un excellent point de départ, pas une couverture complète. Une erreur attendue (une error que votre code renvoie volontairement) ne doit d'ailleurs jamais faire échouer le test : on n'échoue que sur une panique, un invariant violé ou une corruption.
À retenir
Section intitulée « À retenir »- Le fuzzing Go est natif depuis 1.18 : aucune dépendance, juste
go test -fuzz. - Un test de fuzzing est une fonction
FuzzXxx(f *testing.F)avec un seulf.Fuzzet des types scalaires uniquement. - Le test de propriété (round-trip
Decode(Encode(s)) == s) est le patron le plus productif pour un encodeur ou un parseur. - Le moteur minimise l'entrée fautive (ici 38 octets ramenés à 1) et l'écrit dans
testdata/fuzz/comme test de régression versionné. - Rejouez un crash connu avec
-run=FuzzXxx/<hash>, sans re-lancer de campagne. - Le fuzzer remonte le cas le plus court d'abord : ici le bug UTF-8 (
[]runeremplace un octet invalide par U+FFFD) avant le bug de format ambigu sur les chiffres. - Corriger un décodeur d'octets se fait sur
[]byteavec un format non ambigu ; la version corrigée passe 9,5 millions d'exécutions sans échec. - Le natif ne détecte ni data races, ni overflows, ni fuites de goroutines : complétez avec
-raceet, au besoin, un outil tiers.