Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Fuzzing natif en Go : trouver les bugs imprévus

Ou : Comment laisser le compilateur Go générer des inputs plus pervers que ton pire utilisateur

Tu as écrit un test. Trois cas : le happy path, un cas vide, un cas un peu chelou. Tu trouves ça raisonnable. L’IA en rajoute cinq, parce qu’elle aime les chiffres ronds. Vous avez maintenant huit cas de test. Tu as 92 % de coverage. Tu pousses. La nuit, en production, un utilisateur copie-colle le contenu d’un email avec un caractère invisible Unicode dedans. Ta fonction panic. Le service tombe. Personne n’avait imaginé ce cas. Pas toi. Pas l’IA. Pas le reviewer. C’est précisément le problème.

Tous les tests qu’on a vus dans cette série — table-driven, subtests, httptest, mocks, Testify — partagent le même angle mort : ils valident ce que tu anticipes. Ils sont aussi forts que ton imagination, et ton imagination est limitée par ton expérience. L’IA n’aide pas vraiment, parce que son corpus d’entraînement est, lui aussi, fait de cas anticipés par d’autres humains. Entraîner un modèle sur dix millions de tests humains, ça ne génère pas un test que personne n’a écrit. Ça génère un test moyen.

Le fuzzing, c’est l’inverse. Tu donnes à un moteur une fonction et quelques exemples d’inputs valides ; il génère des millions de variantes pseudo-aléatoires, guidées par la couverture de code, jusqu’à ce que l’une fasse exploser ta fonction. Et il en trouve. Toujours. Y compris dans la stdlib Go elle-même1. Depuis Go 1.18 (mars 2022), tout ça est intégré au testing natif via testing.F. Pas de dépendance, pas de framework, juste go test -fuzz.


Fuzz testing en Go avec testing.F : seed corpus, minimisation et regression

testing.F : la même API qu’avant, en plus aléatoire

Une fuzz function, en Go, ressemble à un test classique sauf qu’elle prend un *testing.F au lieu d’un *testing.T. La signature, le nom, l’emplacement : tout est familier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package validator

import "testing"

func FuzzTruncate(f *testing.F) {
    // 1. Seed corpus : on donne quelques exemples valides
    f.Add("hello world", 5)
    f.Add("", 0)
    f.Add("café", 3)

    // 2. Fuzz function : appelée des millions de fois avec des variantes
    f.Fuzz(func(t *testing.T, s string, max int) {
        got := Truncate(s, max)
        if max < 0 {
            return // entrée invalide, on ignore
        }
        if !utf8.ValidString(got) {
            t.Errorf("Truncate(%q, %d) = %q, sortie UTF-8 invalide", s, max, got)
        }
    })
}

Trois éléments. La fonction s’appelle FuzzTruncate (préfixe obligatoire), elle est dans un fichier _test.go, et elle utilise deux méthodes du *testing.F : Add pour le seed corpus, Fuzz pour la fuzz function elle-même2.

Le contrat avec le moteur de fuzzing tient en deux phrases. Add lui dit : « voilà des inputs valides, sers-t’en comme point de départ pour générer des variantes ». Fuzz lui dit : « voilà ce que tu fais avec chaque input ; si t.Errorf ou si ça panic, tu as trouvé un bug ». Le moteur boucle, mute les inputs (flip de bits, troncature, allongement, substitution UTF-8), et regarde si la couverture de code change. Quand un nouvel input fait exécuter une nouvelle branche, il devient à son tour un seed pour de nouvelles mutations. C’est de l’evolutionary fuzzing guidé par la couverture, héritage direct d’AFL de Michal Zalewski et de la lib dvyukov/go-fuzz qui a porté l’écosystème Go pendant des années avant que la stdlib n’absorbe la feature3.


Le truncate qui semblait innocent

Reprenons le code testé. Voici la version « écrite par l’IA en trois secondes » :

1
2
3
4
5
6
7
8
package validator

func Truncate(s string, max int) string {
    if len(s) <= max {
        return s
    }
    return s[:max] + "..."
}

Tu lui demandes des tests, elle te génère :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func TestTruncate(t *testing.T) {
    cases := []struct {
        in   string
        max  int
        want string
    }{
        {"hello", 10, "hello"},
        {"hello world", 5, "hello..."},
        {"", 0, ""},
        {"abc", 3, "abc"},
    }
    for _, c := range cases {
        if got := Truncate(c.in, c.max); got != c.want {
            t.Errorf("Truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
        }
    }
}

Quatre cas. Tous passent. Coverage 100 %. Code review approuvée. Merge. C’est plausible parce que c’est plausible — c’est la définition du code IA.

Maintenant tu lances :

1
go test -fuzz=FuzzTruncate -fuzztime=10s ./validator

Quinze secondes plus tard :

--- FAIL: FuzzTruncate (0.42s)
    --- FAIL: FuzzTruncate (0.00s)
        validator_test.go:18: Truncate("café", 3) = "ca\xc3...", sortie UTF-8 invalide

Failing input written to testdata/fuzz/FuzzTruncate/4f3a1b...

Le bug. len("café") == 5 parce que é occupe deux octets en UTF-8. s[:3] coupe pile au milieu du é et te laisse un demi-caractère orphelin (0xC3), techniquement un byte mais pas un point de code valide. Ta fonction renvoie "ca\xc3...", ce qui est une string Go légale (les strings Go ne sont pas garanties UTF-8) mais que json.Marshal, template.Execute, ou n’importe quel client HTTP downstream va refuser ou corrompre. Et personne n’avait écrit ce test, parce que personne ne se réveille en pensant « ah tiens, et si je truncatais au milieu d’un emoji ? ». Le fuzzer, lui, mute "hello world" un caractère à la fois, finit par essayer une string contenant un caractère multi-byte, et déclenche le bug en quelques millisecondes.

Le fuzzer mute les seeds en continu. La plupart des inputs passent. Un seul fait exploser la fonction.

C’est le pattern de référence du fuzzing : tu écris une propriété qui doit toujours être vraie (« la sortie est UTF-8 valide »), tu laisses le moteur trouver l’input qui la viole. Tu n’as pas besoin de connaître le bug à l’avance — tu as besoin de connaître ce que ta fonction promet.


Le seed corpus : pourquoi f.Add n’est pas optionnel

En théorie, le fuzzer fonctionne même sans aucun f.Add. Il commence avec des inputs vides ou de bytes aléatoires et explore à partir de là. En pratique, sans seed corpus, il met des heures à trouver le moindre cas intéressant, parce qu’il passe l’essentiel de son temps à apprendre la « grammaire » de tes inputs (quelles structures parsent, quels champs existent, quels formats sont valides).

Donner un seed, c’est lui donner le point de départ. Trois conseils :

  1. Au moins un seed par branche identifiable de ton code. Si ta fonction a un cas spécial pour les strings vides, tu donnes une string vide. Si elle traite les chaînes UTF-8 et ASCII différemment (parfois implicitement), tu donnes une instance de chaque.
  2. Des seeds réalistes, pas des cas pathologiques. Tu veux que le moteur découvre les pathologies tout seul. Si tu lui donnes déjà l’input qui crashe, le test passe le premier coup et tu n’as rien gagné.
  3. Pas trop de seeds. Au-delà d’une dizaine, tu introduis surtout du bruit ; le moteur ne sait plus lesquels muter en priorité.

Une autre voie : Go cherche aussi des seeds dans testdata/fuzz/<NomDuFuzzTest>/. Chaque fichier dans ce dossier est un input persisté, et le moteur l’utilise comme seed à chaque run. C’est précisément là qu’il écrit automatiquement les inputs qui ont fait échouer un fuzzing précédent (d’où le Failing input written to testdata/fuzz/FuzzTruncate/4f3a1b...). Tu commits ce fichier dans Git, et il devient un test de régression : la prochaine fois que go test ./... tourne (sans flag -fuzz), il rejouera ce cas comme un test classique. Si quelqu’un réintroduit le bug, le test échoue immédiatement, sans avoir besoin de relancer le fuzzing.


Les types supportés (et ceux qui ne le sont pas)

Le f.Fuzz accepte un nombre arbitraire d’arguments après le *testing.T, mais leurs types sont contraints. Liste exhaustive : string, []byte, bool, byte, rune, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64. C’est tout. Pas de map. Pas de slice autre que []byte. Pas de struct. Pas d’interface. Pas de pointeur.

Cette limitation est volontaire — le moteur a besoin d’une représentation binaire stable et mutable pour chaque input, et les types arbitraires rendraient la mutation chaotique. Mais ça change ta façon d’écrire les fuzz tests. Si tu veux fuzzer une fonction qui prend une struct, tu fuzzes les bytes qui la décrivent et tu laisses la fuzz function la reconstruire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func FuzzParseUserJSON(f *testing.F) {
    f.Add([]byte(`{"name":"Alice","age":30}`))
    f.Add([]byte(`{}`))
    f.Add([]byte(`{"age":-1}`))

    f.Fuzz(func(t *testing.T, data []byte) {
        var u User
        err := json.Unmarshal(data, &u)
        if err != nil {
            return // un JSON invalide n'est pas un bug
        }
        // Propriété : un User unmarshalé puis remarshalé doit être idempotent
        out, _ := json.Marshal(u)
        var u2 User
        if err := json.Unmarshal(out, &u2); err != nil {
            t.Errorf("round-trip cassé : %q → %q → %v", data, out, err)
        }
    })
}

Le pattern « round-trip » est l’un des plus puissants du fuzzing : si parse puis marshal puis parse ne donne pas le même résultat que le premier parse, tu as un bug. Tu n’as même pas besoin de savoir lequel — le fuzzer te livre l’input minimal qui le déclenche. C’est exactement le pattern qui a permis de trouver des bugs dans encoding/json lui-même1 : le fuzz target produisait un JSON, le re-marshalait, et comparait. Le moteur trouvait régulièrement des inputs où la propriété d’idempotence ne tenait pas — typiquement des cas avec clés dupliquées ou caractères encodés à la limite de la spec.


La minimisation : du chaos à un cas reproductible

Quand le fuzzer trouve un input qui fait échouer ton test, il ne se contente pas de te le sauvegarder tel quel. Il essaie automatiquement de le réduire : il enlève des bytes, modifie des caractères, raccourcit la string — tant que la fonction continue d’échouer, l’input réduit devient le nouveau cas de référence. C’est la minimisation, et c’est probablement la fonctionnalité la plus sous-estimée du fuzzing natif.

L’input brut qui a déclenché le bug peut faire 4 Ko, contenir des séquences UTF-8 hallucinées, des bytes nuls et trois lignes de garbage. L’input minimisé fait souvent 5 caractères et te dit immédiatement ce que tu as cassé. Pour le Truncate plus haut, le moteur va te livrer quelque chose comme "é", max=1 ou "€", max=1 — un cas que tu peux lire en deux secondes et comprendre en cinq.

La minimisation : passer d'un input chaotique de plusieurs Ko à l'instance la plus courte qui reproduit encore le bug.

Petit piège technique : dans la fuzz function, (*F).Log, (*F).Error, (*F).Skip sont interdites. Tu dois utiliser les méthodes équivalentes du *testing.T qui t’est passé en premier argument. Ne pas le savoir produit une panic plus déroutante que le bug que tu cherchais. Le compilateur ne te le signale pas, c’est un check runtime du framework. C’est typique des features ajoutées tard à un type stable : la nouveauté hérite des contraintes de l’ancien.


Ce que le fuzzing ne fait pas

Le fuzzing natif Go est simple à utiliser. Il n’est pas, dans l’absolu, le moteur de fuzzing le plus performant qui existe. libFuzzer (LLVM), AFL++, Honggfuzz — tous ont une décennie d’avance sur l’instrumentation, la mutation et la stratégie de coverage. La conséquence pratique : sur des fonctions de parsing complexes, un fuzzer C ou Rust trouvera des bugs en quelques secondes là où Go peut tourner pendant des heures sans rien produire de neuf.

Cela dit, deux choses tempèrent. D’une part, OSS-Fuzz — le service de fuzzing continu de Google qui tourne 24/7 sur les projets open source critiques — supporte les fuzz tests Go natifs depuis 2022 et les compile en binaires libFuzzer. Tu écris ton fuzz test une fois en testing.F, OSS-Fuzz le fait tourner pendant des semaines sur leur infrastructure ; tu reçois un mail quand il trouve quelque chose. C’est gratuit pour les projets open source. C’est très bien.

D’autre part, la perfection technique du moteur compte moins qu’on le croit dans la pratique. Le bug du Truncate plus haut se trouve en deux secondes avec n’importe quel fuzzer, parce que la propriété testée (UTF-8 valide) est facile à violer dès qu’on touche aux strings. La majorité des bugs que le fuzzing va trouver dans ton code, c’est ce genre de bugs : pas des CVE de niveau Linux kernel, juste des cas que personne n’avait imaginés et qui font crash en prod. Pour ça, le moteur natif Go suffit largement, et il a l’énorme avantage de coûter zéro friction d’installation.


Là où l’IA est mise à nu

Le truc surprenant en utilisant le fuzzing sur du code généré par IA, c’est la régularité avec laquelle il trouve des bugs. Pas des cas exotiques. Des cas vraiment basiques : strings vides, valeurs négatives, overflow d’entiers à MaxInt, slice de longueur zéro, runes Unicode supplémentaires. L’IA produit du code qui passe ses propres tests parce qu’elle teste les cas qui ressemblent à ce qu’elle a vu pendant l’entraînement. Le fuzzer teste les cas qui ne ressemblent à rien.

Junior Jules : J’ai demandé à Claude de me coder un parser CSV. Il l’a fait, j’ai écrit une dizaine de tests, tout passe.

Senior Sam : Tu as fuzzé ?

Junior Jules : Fuzzé ?

Senior Sam : f.Add un CSV valide, f.Fuzz qui parse l’input et vérifie qu’il ne panic pas. Lance go test -fuzz pendant trente secondes.

(deux minutes plus tard)

Junior Jules : Il a trouvé un panic sur un input avec un guillemet sans fermeture suivi de plusieurs caractères Unicode RTL.

Senior Sam : En combien de temps ?

Junior Jules : Onze secondes.

Senior Sam : Donc tu as gagné un parser CSV cassable par n’importe quel utilisateur turc ou arabe. Bien joué.

Junior Jules : Comment je corrige ?

Senior Sam : Tu commits le seed dans testdata/fuzz/, tu fais corriger le bug par l’IA, et tu vérifies que le test de régression passe. Ensuite tu relances le fuzzing pendant cinq minutes pour voir s’il trouve autre chose.

Le pattern, ici, n’est pas anti-IA. Au contraire : tu utilises l’IA pour générer le code et les tests rapidement, et tu utilises le fuzzer pour valider ce que ni elle ni toi n’auriez pensé à tester. Les deux outils se complètent — mais seulement si tu connais le second.


Ce que tu peux faire maintenant

  1. Choisis une fonction « pure » de ton projet (pas d’I/O, pas de DB) qui prend des inputs primitifs : un parser, un validateur, un encodeur, un slugifier. Écris une fuzz function avec deux ou trois f.Add réalistes et une assertion sur une propriété invariante (pas de panic, sortie UTF-8 valide, round-trip identique).
  2. Lance go test -fuzz=FuzzXxx -fuzztime=30s ./.... Trente secondes suffisent pour 80 % des bugs faciles à trouver.
  3. Si le fuzzer trouve quelque chose, le fichier dans testdata/fuzz/ est ton nouveau test de régression. Commit-le tel quel, corrige le bug, et go test ./... rejouera ce cas à chaque CI sans avoir besoin du flag -fuzz.
  4. Configure une CI qui lance un fuzzing court (deux à cinq minutes) sur les fuzz tests modifiés depuis le dernier commit. Tu n’as pas besoin de faire tourner des heures à chaque PR — quelques minutes attrapent l’essentiel des régressions.
  5. Si ton projet est open source et populaire, demande l’inclusion à OSS-Fuzz. Ils font tourner ton fuzzer pendant des semaines sur leur infrastructure, gratuitement.

La suite

On a appris à générer des inputs imprévus pour trouver des bugs imprévus. La logique de notre code est maintenant testée sous tous les angles : cas anticipés (table-driven), cas isolés (subtests), cas HTTP (httptest), cas avec dépendances (mocks), cas par assertion (Testify), cas inimaginables (fuzzing). Reste un angle qu’on n’a pas encore touché : la performance. L’IA prétend régulièrement « optimiser » du code Go. Est-ce que c’est vrai ? Est-ce que sa nouvelle version est réellement plus rapide que l’ancienne, ou juste différente ?

Dans le prochain article, on regarde les benchmarks et le profiling Go natifs : testing.B, b.N, b.ResetTimer, b.ReportAllocs, go test -bench, benchstat pour comparer deux runs, et pprof pour voir où le CPU et la mémoire passent vraiment. Avec un exercice central : prendre une fonction, demander à l’IA de l’optimiser, et benchmarker les deux versions pour vérifier si l’optimisation est réelle ou hallucinée. Spoiler : les deux cas existent, et le seul moyen de les distinguer, c’est de mesurer.


  1. L’exemple historique le plus documenté côté encoding/json est un crash par dépassement de slice trouvé via fuzzing en 2019 et tracké dans l’issue #33728 — la régression avait été introduite par une optimisation de Go 1.13 (CL 151157), détectée par OSS-Fuzz côté Chromium, puis revertée. Pendant le prototypage de la feature « fuzzing as a first-class citizen » discutée dans l’issue #31309, l’équipe Go a aussi documenté plusieurs faux positifs sur le round-trip JSON — typiquement des cas avec clés dupliquées qui révélaient des ambiguïtés de spec plutôt que de vrais bugs. Toute cette exploration s’est faite avec dvyukov/go-fuzz avant que la stdlib n’absorbe la feature en Go 1.18. ↩︎ ↩︎

  2. La signature complète documentée ici : un fuzz target est un func FuzzXxx(f *testing.F) dans un fichier _test.go ; à l’intérieur, f.Add(args...) prépare le seed corpus et f.Fuzz(func(t *testing.T, args ...)) déclare la fuzz function. Les types des args doivent matcher exactement entre f.Add et la signature de la fuzz function. Une seule fuzz function par fuzz target. Les méthodes (*F).Log, (*F).Error, (*F).Skip sont interdites à l’intérieur de la fuzz function — il faut utiliser les méthodes équivalentes sur le *T. Cette dernière règle est un check runtime, pas une erreur de compilation, ce qui garantit que tu te feras avoir au moins une fois. ↩︎

  3. Avant Go 1.18, le fuzzing en Go passait par github.com/dvyukov/go-fuzz, maintenu par Dmitry Vyukov (Google), qui s’inspirait directement d’american fuzzy lop (AFL) de Michal Zalewski — l’outil qui a démocratisé l’evolutionary fuzzing au début des années 2010 et trouvé des centaines de CVE dans des projets comme OpenSSL, GnuPG ou les browsers. La proposal officielle de fuzzing natif Go référence explicitement go-fuzz comme inspiration, et la feature est arrivée dans la stdlib après plusieurs années de discussion (issue #44551). Le moteur actuel n’est pas aussi rapide que libFuzzer ou AFL++, mais l’avantage structurel d’être intégré à go test rend la barrière d’entrée presque nulle — et c’est probablement plus important pour la qualité moyenne du code Go que cinq pourcents de mutations par seconde en plus. ↩︎