Amazon Web Services ブログ

プロパティベーステストが見つけた、私が決して発見できなかったセキュリティバグ

本記事は「Property-Based Testing Caught a Security Bug I Never Would Have Found」を翻訳したものです。

ターゲット型ランダムテストが実際のセキュリティ脆弱性を発見したとき

セキュリティ脆弱性は、私たちがテストしようと思わないコードの隅に隠れていることがよくあります。正常系テストを書き、想像できるいくつかの境界値ケースをテストしますが、考えもしない入力についてはどうでしょうか? LLM がデフォルトでこれらのシナリオを処理していると仮定することが多いですが、LLM が生成したコードも人間が書いたコードと同様にバグや脆弱性を含む可能性があります。ユーザーがアプリケーションに悪意のある文字列を入力したらどうなるでしょうか?

これは、Kiro の最新の GA 機能を使用して AI でチャットアプリケーション用のストレージサービスを構築するテストを行ったときに起こったことです。仕様駆動開発(SDD)ワークフローに従って、Kiro は要件を慎重に定義し、テスト可能なプロパティを抽出し、API キーの保存と取得のための一見単純なコードを実装しました。実装は堅実に見えました。コードレビューでも承認されたでしょう。従来の単体テストも通過したでしょう。

しかし、プロパティベーステストの 75 回目の反復で、予期しないことが起こりました。ラウンドトリップケースのプロパティテスト全体が失敗したのです。単純な保存と取得操作であるはずが、代わりに JavaScript プロトタイプの誤った処理を露呈しました。これは、早期に欠陥を排除するよう注意しないと、将来的にセキュリティ問題につながる可能性があるバグです。

この投稿では、プロパティベーステスト(PBT)が人間の直感や従来のテスト手法では見逃されたであろうセキュリティバグをどのように発見したかのストーリーを紹介します。以下について説明します。

  • Kiro が定義した仕様とプロパティ
  • 重大な欠陥を含んでいた一見無害な実装
  • PBT の入力空間の体系的な探索が脆弱性をどのように発見したか
  • 脆弱性に対処する修正
  • これが安全なソフトウェア構築にとってなぜ重要なのか

これは単なる理論的な演習ではありません。自動テスト技術が、セキュリティ研究者を夜も眠れなくするエッジケースを、本番環境に到達する前に発見できることの実例です。

背景

一部の顧客とアプリケーションの構築に取り組み、仕様のプロンプトを検討する際、Kiro はユーザーデータをブラウザの localStorage に保存するチャットアプリケーション用のストレージシステムを実装していました。主要な機能の一つは、異なる LLM プロバイダー(OpenAI、Anthropic など)の API キーを保存することでした。ユーザーはプロバイダー名をキーとして API キーを保存できます。このオブジェクトは以下のような API を持ちます。

storageService.saveApiKey("openai", "sk-abc123...");
storageService.saveApiKey("anthropic", "sk-ant-xyz...");

Kiro は SDD に従って以下の要件を策定しました。

### 要件 6
**ユーザーストーリー:** ユーザーとして、異なる LLM プロバイダーの API キーを設定したい。そうすることで、自分のアカウントを使用してコストを管理できる。

#### 受け入れ基準
1. ユーザーが設定を開いたとき、チャットアプリケーションは各 LLM プロバイダーの API キー入力フィールドを表示する
2. ユーザーが API キーを保存したとき、チャットアプリケーションはそれをローカルストレージに安全に保存する
3. API キーが無効または欠落している場合、チャットアプリケーションは明確なエラーメッセージを表示し、メッセージ送信を防ぐ
4. チャットアプリケーションはセキュリティのため UI で API キー値をマスクする
5. ユーザーが API キーを削除したとき、チャットアプリケーションはその LLM プロバイダーを無効にする

受け入れ基準 2 について詳しく見てみましょう。Kiro はこれを重要な正確性プロパティとして選択しました。

**プロパティ 19: API キーストレージのラウンドトリップ**
*任意の* プロバイダーに保存された API キーについて、ストレージから取得すると同じキー値が返される。

**検証対象: 要件 6.2**

Kiro はこれを「ラウンドトリップ」プロパティと呼んでいます。ラウンドトリップは正確性プロパティの一般的な形で、任意の値から始めて、一連の操作を実行し、同じ値で終わるものです。この場合、任意の文字列値 providerkey から始めて以下を行いました。

  1. ストレージの provider の下に key を保存
  2. provider に関連付けられた値を取得

そして、取得した値は key と等しくなければなりません。これが真でない場合(異なる値を取得したり、例外が発生したりする場合)、明らかに実装に何か問題があります。この仕様は素晴らしく見えるので、承認して Kiro に API を実装してもらいます。

LLM は API の一部として以下のコードを生成しました。

/**
 * 特定のプロバイダーの API キーを保存
 */
saveApiKey(provider: string, apiKey: string): void {
  try {
    const apiKeys = this.loadAllApiKeys();
    apiKeys[provider] = apiKey;
    localStorage.setItem(
      StorageService.API_KEYS_KEY,
      JSON.stringify(apiKeys)
    );
  } catch (error) {
    if (error instanceof Error && error.name === 'QuotaExceededError') {
      throw new Error('ストレージクォータを超過しました。API キーを保存できません。');
    }
    throw error;
  }
}

その後、Kiro はプロパティベーステストを使用してこのコードをテストし、期待するプロパティが実際に成り立つという証拠を収集しました。プロパティ 19 をチェックするために、Kiro は TypeScript 用の fast-check ライブラリを使用して以下のテストを書きました。

describe('プロパティ 19: API キーストレージのラウンドトリップ', () => {
  /**
   * 機能: llm-chat-app, プロパティ 19: API キーストレージのラウンドトリップ
   * 検証対象: 要件 6.2
   *
   * プロバイダーに保存された任意の API キーについて、ストレージから取得すると
   * 同じキー値が返される。
   */
  it('保存と読み込みサイクルを通じて API キーを保持する', () => {
    fc.assert(
      fc.property(
        fc.string({ minLength: 1, maxLength: 100 }), // プロバイダー名
        fc.string({ minLength: 10, maxLength: 200 }), // API キー
        (provider, apiKey) => {
          // 各プロパティテスト実行前に localStorage をクリア
          global.localStorage.clear();

          // API キーを保存
          storageService.saveApiKey(provider, apiKey);

          // 読み込み直す
          const loaded = storageService.loadApiKey(provider);

          // 元の値と一致することを確認
          expect(loaded).toBe(apiKey);
        }
      ),
      { numRuns: 100 }
    );
  });

Kiro がこのテストを実行すると、試行 #75 で失敗が発生しました!Kiro は失敗を Shurinking し、以下の反例を報告しました。プロバイダー "__proto__" と API キー " "

何が起こっているのか?

プロパティベーステストはプロバイダー名にランダムな文字列を生成し、75 回のテスト実行後、プロバイダー名として文字列 "__proto__" を生成しました。これにより、以下の反例でテストが失敗しました。

反例: ["__proto__"," "]

プロバイダー名 __proto__ で API キーを保存してから読み込もうとすると、奇妙なことが起こり、期待した値を取得できません。Kiro は Shurinking を使用して最小反例を提示して問題を特定し、問題から余分な詳細を取り除くのに役立ちます。この場合、apiKey 文字列をジェネレーターで許可される最小の文字列(スペースのみを含む)に Shurinking します。これは、問題が値ではなく、奇妙なキーが問題を引き起こしていることを示しています。JavaScript に詳しい方なら、このエラーはすぐに目に付くでしょうが、そうでない方は読み続けてください。

これは JavaScript がオブジェクトシステムを実装する方法の特徴です。より伝統的なオブジェクト指向プログラミング言語(Java、Python、SmallTalk など)は、クラスの概念を使用します。各クラスは、オブジェクトの構築方法を記述し、異なるオブジェクト間の継承関係を記述するコードベースの静的メンバーです。JavaScript は「プロトタイプ」と呼ばれる代替アプローチを使用します。プロトタイプベースのオブジェクトシステムでは、クラスは存在しません。代わりに、すべてのオブジェクトには、コードとデータを継承すべき親オブジェクトを指すプロトタイプと呼ばれる特別なフィールドが含まれています。これにより、継承関係を動的に設定できます。JavaScript では、このプロトタイプは __proto__ フィールドに存在します。フィールドを文字列に設定しようとしたとき、JavaScript エンジンはこれを拒否し、元のプロトタイプをそのまま保持しました。これにより、プロパティテストの第 2 ステップで provider を検索したときに、元のプロトタイプ(空のオブジェクト)を取得することになります。

プロトタイプへの書き込みが例のように無害というわけではありません。providerapiKey は攻撃者の制御下にあるため、攻撃者が apiKey に文字列以外の値を取得する方法を見つけた場合、プロトタイプに値を注入でき、オブジェクトのプロパティからのさらなる読み取りが攻撃者制御の値を返す可能性があります。

これは悪用可能でしょうか?いいえ。apiKeys オブジェクトは十分に長く存在せず、シリアル化後すぐに解放され、JSON.stringify__proto__ フィールドをスキップすることを知っています。また、グローバルプロトタイプを変更するのではなく、apiKeys のプロトタイプのみを上書きしています。しかし、コードのリファクタリングにより、この悪用不可能な脆弱性をより広範囲な影響を与える可能性のあるものに変える新しいコードパスが導入される可能性があります。プロパティベーステストが提供するテスト力は、これを即座に捕捉して、コードベースにおいて微妙な不正確さや難しいエッジケースが増えるのを防ぐのに役立ちます。

Kiro はこれをどのようにテストしたのか?

プロバイダー名 __proto__ で API キーを保存してから読み込もうとしたとき、保存した API キーの代わりに空のオブジェクト {} を取得しました。なぜこれが起こったのでしょうか?内部で何が起こったかについてもう少し背景を理解しましょう。

PBT の利点の1つと言われているのはバイアスです。単体テストでは、テストを書いた人(モデルまたは人間)がエッジケースを考慮しようとしましたが、自分自身の内部バイアスによって制限されています。同じ(モデル/人)が実装を書いたので、実装中に考えなかったエッジケースを思いつくのは困難だと考えるのが妥当です。この場合、プロパティベーステストを使用することで、テストフレームワークを作った人たちの集合知が使えます。この場合、一般的なバグタイプの体系的知識“をプロセスに注入しています。(__proto__ は、fast-check コミュニティの作者によって PBT ジェネレーターにエンコードされた一般的なバグ文字列の一つです)をテストプロセスに注入しています。

続行する前に注意すべき点は、PBT コードに { numRuns: 100 } があることです。これは、ジェネレーターがバグを見つけようとする 100 回の反復があることを意味します。Kiro はこれをデフォルトにしていますが、プログラムに求める信頼レベルに応じて、この値を上げたり下げたりできます。時にはもっと必要ですが、実装のテストに少し時間がかかるため、100 回以上の入力テストを実行するパフォーマンスが開発ライフサイクルのその段階ではまだ価値がない場合もあります。良い点は、必要に応じていつでもこれを上げたり下げたりできることです。

修正

Kiro は MITRE の高効果緩和戦略に基づいて 2 つの防御策を実装しました。

1. 安全な保存(saveApiKey 内)

// プロトタイプ汚染を避けるため null プロトタイプオブジェクトを作成
const safeApiKeys = Object.create(null);
Object.assign(safeApiKeys, apiKeys);
safeApiKeys[provider] = apiKey;

Object.create(null) で作成されたオブジェクトにはプロトタイプチェーンがないため、__proto__ は単なる通常のプロパティになります。

2. 安全な取得(loadApiKey 内)

// hasOwnProperty を使用してキーを安全にチェック
return Object.prototype.hasOwnProperty.call(apiKeys, provider)
  ? apiKeys[provider]
  : null;

より大きな視点

このストーリーは、Kiro が SDD の一部としてプロパティベーステストを使用する理由を示しています:

  1. プロパティは要件に直結 – 「任意のプロバイダー名について、ラウンドトリップする」というプロパティは、要件をそのまま変換したものです。
  2. ランダム生成は予期しないエッジケースを発見 – 人間と LLM は、テストする入力についてバイアスを持っています。ランダム生成はテストケースを徹底的に追い込みます
  3. 実行可能な仕様 – プロパティは実行できる仕様です。「コードは何をすべきか」(要件)と「コードは実際にそれを動かすのか」(テスト)の間のギャップを埋めます。
  4. タイトなフィードバックループ – プロパティが失敗すると、デバッグを容易にする最小限の反例を取得します。Kiro はこれを使用してコードを修正し、迅速な反復サイクルを作成できます。

このバグは Kiro での実際の開発中に発見されました。プロパティベーステストは、以下の方法では発見が非常に困難だったであろうセキュリティ弱点をキャッチしました。

  • 手動コードレビュー
  • 手動で選んだ例を使った従来の単体テスト
  • 統合テスト