Go言語入門:Fuzzing -Vol.6-

スポンサーリンク
Go言語入門:Fuzzing -Vol.6- ノウハウ
Go言語入門:Fuzzing -Vol.6-
この記事は約13分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のFuzzingついて解説しています。

スポンサーリンク

背景

Goでテストを書いていると、「Fuzzing」という言葉を目にすることがあるかもしれません。公式ドキュメントを開いてみたものの、英語で書かれていて「coverage guidance」「edge cases」といった専門用語が並び、正直なところ「何をするものなのか、よくわからない…」と感じた方も多いのではないでしょうか。

実は私も最初は同じでした。「自動テスト」「入力を操作」「セキュリティ脆弱性の発見」と聞いても、具体的にどう使えばいいのか、なぜ必要なのかがピンと来なかったんです。

そこでこの記事では、Go公式ドキュメントの「Fuzzing」について、丁寧な日本語訳はもちろん、初心者の方にもわかるように補足説明や具体例を交えながら解説していきます。Fuzzingは一見難しそうに見えますが、実は「人間が思いつかないようなテストケースを自動で試してくれる便利な仕組み」なんです。

テストコードをより強固にしたい、セキュリティに配慮したコードを書きたいと考えている方にとって、Fuzzingは強力な味方になってくれます。一緒に学んでいきましょう!

失敗する入力

ファジング中に失敗が発生する理由はいくつかあります:

  • コードまたはテスト内でpanicが発生した。
  • ファズターゲットが、t.Errort.Fatalなどのメソッドを通じて直接的または間接的にt.Failを呼び出した。
  • os.Exitやスタックオーバーフローなど、回復不可能なエラーが発生した。
  • ファズターゲットの完了に時間がかかりすぎた。現在、ファズターゲットの実行のタイムアウトは1秒です。これは、デッドロックや無限ループ、またはコード内の意図的な動作によって失敗する可能性があります。これが、ファズターゲットを高速にすることが推奨される理由の1つです。

エラーが発生した場合、ファジングエンジンは、エラーを引き起こす最小かつ最も人間が読みやすい値に入力を最小化しようと試みます。これを設定するには、カスタム設定のセクションを参照してください。

最小化が完了すると、エラーメッセージがログに記録され、出力は次のようなもので終了します:

    Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
    To re-run:
    go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
FAIL
exit status 1
FAIL    foo 0.839s

ファジングエンジンは、この失敗する入力をそのファズテストのシードコーパスに書き込み、go testでデフォルトで実行されるようになります。バグが修正されると、リグレッションテストとして機能します。

次のステップは、問題を診断し、バグを修正し、go testを再実行して修正を検証し、新しいtestdataファイルをリグレッションテストとして含めてパッチを送信することです。


解説

ファジングでバグが見つかったときの流れ

ファジングの最大の目的はバグを見つけることです。バグが見つかったときに何が起こるのか、そしてどう対処すればいいのかを学びましょう。


バグが見つかる4つの原因

1. Panic(パニック)

プログラムがクラッシュした場合です。

func Reverse(s string) string {
    // バグ: 空文字列でpanicする
    return string(s[len(s)-1])  // 💥 panic: index out of range
}

ファジングの出力:

--- FAIL: FuzzReverse (2.15s)
    panic: runtime error: index out of range [18446744073709551615] with length 0

2. テストの失敗(t.Error、t.Fatal)

期待する結果と実際の結果が違う場合です。

func FuzzReverse(f *testing.F) {
    f.Add("hello")
    
    f.Fuzz(func(t *testing.T, input string) {
        reversed := Reverse(input)
        doubleReversed := Reverse(reversed)
        
        if input != doubleReversed {
            t.Errorf("Reverse(Reverse(%q)) = %q, want %q", 
                input, doubleReversed, input)  // 💥 失敗
        }
    })
}

ファジングの出力:

--- FAIL: FuzzReverse (3.42s)
    reverse_test.go:15: Reverse(Reverse("🎉")) = "", want "🎉"

3. 回復不可能なエラー

プログラムが強制終了する場合です。

func Process(input string) {
    if input == "exit" {
        os.Exit(1)  // 💥 プログラム強制終了
    }
}

スタックオーバーフローも含まれます:

func Recursive(n int) int {
    return Recursive(n + 1)  // 💥 無限再帰でスタックオーバーフロー
}

4. タイムアウト(1秒超過)

処理に時間がかかりすぎる場合です。

func FuzzSlow(f *testing.F) {
    f.Fuzz(func(t *testing.T, input string) {
        // 💥 無限ループ
        for {
            // 永遠に終わらない
        }
    })
}

または:

func FuzzDeadlock(f *testing.F) {
    f.Fuzz(func(t *testing.T, input string) {
        ch := make(chan int)
        <-ch  // 💥 デッドロック(誰もデータを送らない)
    })
}

タイムアウトのルール:

  • 各テストは1秒以内に完了しなければならない
  • これがファズターゲットを高速に保つべき理由

バグが見つかったときに何が起こるか?

ステップ1: 入力の最小化(Minimization)

ファジングエンジンは、バグを引き起こす最小の入力を見つけようとします。

なぜ最小化するのか?

大きくて複雑な入力より、小さくてシンプルな入力の方がバグの原因を理解しやすいからです。

:

バグを引き起こす入力が見つかった:

入力: "aaaaaaaaaaaaaaaaaaaaaaaaa🎉bbbbbbbbbbbbb"

ファジングエンジンが最小化を試みる:

試行1: "🎉bbbbbbbbbbbbb"           → まだバグ発生 ✓
試行2: "🎉bbb"                     → まだバグ発生 ✓
試行3: "🎉"                        → まだバグ発生 ✓
試行4: ""                          → バグ発生しない ✗

最小入力: "🎉"

実際の出力:

fuzz: elapsed: 5s, minimizing
fuzz: elapsed: 5s, minimization done
--- FAIL: FuzzReverse (5.23s)
    reverse_test.go:15: Reverse(Reverse("🎉")) = "", want "🎉"

ステップ2: 失敗する入力の保存

最小化が完了すると、入力は自動的にファイルとして保存されます。

Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49

ディレクトリ構造:

myproject/
├── foo.go
├── foo_test.go
└── testdata/
    └── fuzz/
        └── FuzzFoo/
            ├── seed_file_1
            ├── seed_file_2
            └── a878c3134...  ← 新しく保存された失敗入力

ファイルの中身:

go test fuzz v1
string("🎉")

このファイルは、リグレッションテストとして機能します。


ステップ3: 再実行方法の表示

To re-run:
go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49

このコマンドをコピペすれば、同じバグを再現できます。


バグ修正のワークフロー

実際の開発での流れを見ていきましょう。

1. バグ発見

$ go test -fuzz=FuzzReverse -fuzztime=10s
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
--- FAIL: FuzzReverse (3.15s)
    reverse_test.go:15: Reverse(Reverse("🎉")) = "", want "🎉"
    
    Failing input written to testdata/fuzz/FuzzReverse/a8f3d...
    To re-run:
    go test -run=FuzzReverse/a8f3d...
FAIL

2. バグの再現

保存された入力で、バグを確実に再現できます:

$ go test -run=FuzzReverse/a8f3d...
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/a8f3d... (0.00s)
        reverse_test.go:15: Reverse(Reverse("🎉")) = "", want "🎉"
FAIL

3. バグの診断

コードを調査:

func Reverse(s string) string {
    // 問題: バイト単位で逆転している
    // 絵文字は複数バイトなので壊れる
    b := []byte(s)
    for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}

4. バグの修正

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)
}

5. 修正の検証

普通のテストを実行:

$ go test
=== RUN   FuzzReverse
=== RUN   FuzzReverse/a8f3d...  ← 保存された失敗入力でテスト
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/a8f3d... (0.00s)
PASS
ok      example 0.123s

✅ テスト成功!バグが修正されました。

6. さらにファジング

修正後、もう一度ファジングして他のバグがないか確認:

$ go test -fuzz=FuzzReverse -fuzztime=1m
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 203)
fuzz: elapsed: 60s, execs: 7200000 (120000/sec), new interesting: 25 (total: 217)
PASS
ok      example 60.234s

✅ 1分間ファジングしても問題なし!

7. コミット

git add .
git commit -m "Fix Reverse function to handle multi-byte characters

- Fixed bug where Reverse() didn't handle emoji correctly
- Added fuzz test case for multi-byte characters"

重要: testdata/fuzz/ディレクトリもコミットします!


testdata/fuzz/の役割

保存された失敗入力は、自動的にリグレッションテストになります

myproject/
└── testdata/
    └── fuzz/
        └── FuzzReverse/
            └── a8f3d...  ← このファイル

今後の開発で:

# 普通のgo testを実行すると...
$ go test

=== RUN   FuzzReverse
=== RUN   FuzzReverse/seed#0
=== RUN   FuzzReverse/seed#1
=== RUN   FuzzReverse/a8f3d...  ← 自動的にテストされる!
--- PASS: FuzzReverse (0.00s)
PASS

もし誰かが同じバグを再び入れてしまっても、自動的に検出されます!


実践例: 完全なワークフロー

// reverse.go
package myapp

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)
}
// reverse_test.go
package myapp

import "testing"

func FuzzReverse(f *testing.F) {
    // シードコーパス
    f.Add("hello")
    f.Add("世界")
    
    f.Fuzz(func(t *testing.T, input string) {
        // 二重反転で元に戻るはず
        reversed := Reverse(input)
        doubleReversed := Reverse(reversed)
        
        if input != doubleReversed {
            t.Errorf("Reverse(Reverse(%q)) = %q, want %q",
                input, doubleReversed, input)
        }
        
        // 長さは変わらないはず
        if len([]rune(input)) != len([]rune(reversed)) {
            t.Errorf("length mismatch: got %d, want %d",
                len([]rune(reversed)), len([]rune(input)))
        }
    })
}

まとめ

バグが見つかったときの流れ:

  1. 🔍 ファジングがバグを発見
  2. 🔬 入力を最小化(人間が理解しやすく)
  3. 💾 testdata/fuzz/に自動保存
  4. 🔧 バグを修正
  5. go testで検証
  6. 📝 testdataファイルと一緒にコミット
  7. 🛡️ 以降、自動的にリグレッションテスト

重要なポイント:

  • testdata/fuzz/ディレクトリはバージョン管理に含める
  • ✅ バグ修正後も、そのテストケースは残り続ける
  • ✅ 将来の開発者が同じバグを入れるのを防げる

ファジングは、バグを見つけるだけでなく、永続的なテストスイートを自動構築してくれます!

おわりに 

本日は、Go言語のFuzzingについて解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました