エンジニアリング記事

クラウドMac CIでiOSファイル保護クラスを継続検証する

クラウドMac CIでiOSファイル保護クラスを継続検証する

リモートのビルドノードですべてのテストに合格しても、アプリがディスクへ書き込んだデータが想定どおりに保護されているとは限りません。よくある問題は、開発者が暗号化APIを呼び忘れることではなく、原子的なファイル置換、データベース移行、キャッシュ再構築の後に、元の保護クラスが最終ファイルへ反映されていないことです。この検証をクラウドMacの継続的インテグレーションに組み込めば、特定の書き込みコードだけを確認するのではなく、マージのたびに実際のファイル属性を検証できます。

まず保護対象のデータを定義する

iOSのファイル保護は、厳しければ厳しいほどよいわけではありません。まず「デバイスのロック後も読み取りが必要か」という基準でデータを分類し、そのうえで保護クラスを決めます。トークン、オフライン業務データ、ユーザーが書き出した機密コンテンツは、通常、デバイスのロック後にアクセスできない状態にすべきです。一方、バックグラウンド転送キュー、必要なプッシュ通知の状態、ロック中も動作を継続しなければならないタスクは、個別に評価する必要があります。

データ種別 推奨ベースライン 確認ポイント
セッショントークンのエクスポートファイル 完全保護 ロック後に読み取れないこと
ユーザーの機密文書 完全保護 作成、置換、復元の後も属性が一致すること
バックグラウンドタスクの状態 初回ロック解除後に利用可能 ロック中のアクセスが業務上本当に必要か確認すること
再生成可能なキャッシュ リスクに応じて決定 トークンや個人データを混在させないこと
SQLiteデータベース 業務データと同じ WALファイルとSHMファイルも同時に確認すること

DocumentsまたはLibraryディレクトリ全体を、同じ種類のデータとして扱ってはいけません。ゲートでは、相対パス、期待する保護クラス、ファイルを生成する操作、例外を認める理由を記録した明示的な一覧を管理するのが適切です。

検証対象は、テスト実行後に実際に存在するファイルです。.completeFileProtection という文字列を検索するだけでは、データベースの補助ファイル、置換後の新しいinode、サードパーティ製コンポーネントが作成したデータを見落とします。

XCTestで最終ファイルの属性を読み取る

テストでは、まず実際の業務処理と同じ入口からデータを書き込み、その後、最終URLから fileProtection を読み取ります。次のヘルパーメソッドは、属性がない場合を報告するだけでなく、実際の値も失敗メッセージへ出力するため、CIログから直接原因を特定できます。

import XCTest

final class FileProtectionTests: XCTestCase {
    private func assertProtection(
        _ url: URL,
        equals expected: URLFileProtection,
        file: StaticString = #filePath,
        line: UInt = #line
    ) throws {
        XCTAssertTrue(
            FileManager.default.fileExists(atPath: url.path),
            "Expected file does not exist: \(url.path)",
            file: file,
            line: line
        )

        let values = try url.resourceValues(forKeys: [.fileProtectionKey])
        XCTAssertEqual(
            values.fileProtection,
            expected,
            "Unexpected protection at \(url.path): \(String(describing: values.fileProtection))",
            file: file,
            line: line
        )
    }

    func testSensitiveExportUsesCompleteProtection() throws {
        let root = FileManager.default.temporaryDirectory
        let url = root.appendingPathComponent("private-export.json")
        let payload = Data(#"{"status":"ready"}"#.utf8)

        try payload.write(to: url, options: [.atomic, .completeFileProtection])
        try assertProtection(url, equals: .complete)
    }
}

テストデータに実際の認証情報を含めてはいけません。パスもテストコンテナ内で動的に生成し、特定ノードのユーザー名や固定の作業ディレクトリに依存しないようにします。製品が既存ファイルへの上書き保存に対応している場合は、テストで2回連続して書き込む必要があります。2回目の書き込みでは、通常、一時ファイルを作成してから名前を変更する処理が使われるためです。

例外を明示的に一覧化する

.completeUntilFirstUserAuthentication を本当に必要とするファイルは、テスト内で1件ずつ明示しなければなりません。「完全保護でなければ合格」といった曖昧な条件は認められません。例外一覧には、業務上の利用場面と担当者も併記します。ロック中のアクセスが不要になった時点で例外を削除し、保護クラスを引き上げます。

SQLiteの補助ファイルを見落とさない

SQLiteはWALモードで database.sqlite-waldatabase.sqlite-shm を作成することがあります。メインファイルだけを確認すると、安全性について誤った結論に至ります。テストでは、まず実際の書き込みトランザクションを1回実行し、補助ファイルが生成されたことを確認してから、3つのファイルすべてに同じアサーションを適用します。

Core Dataを使用している場合、空のデータベースを手作業で作るのではなく、永続コンテナを通じて挿入と保存を実行します。一部の補助ファイルは接続を閉じた後に消えるため、書き込みトランザクションの完了後、ストアを閉じる前に確認しなければなりません。次の情報を記録しておくことを推奨します。

  1. メインデータベース、WAL、SHMの絶対パス。
  2. 各ファイルの実際の保護クラス。
  3. ファイル作成のきっかけとなったテスト手順。
  4. ファイルが存在しない場合、それが想定されたクリーンアップなのか、テストが実際には書き込みを実行していないのか。

ダウンロード後にデータベースをインポートする処理では、「一時ファイルへのダウンロード—検証—正式ディレクトリへの移動」という一連の経路も対象にします。移動先ディレクトリに正しい属性が設定されていても、移動後のファイルが同じポリシーを自動的に継承するとは限りません。

クラウドMacにビルドゲートを組み込む

ゲートは独立したテストプランとして実行できるため、毎回すべてのUIテストを走らせる必要はありません。プロジェクト、Scheme、シミュレータデバイス、結果バンドルのパスを固定し、xcodebuild の終了コードによってパイプラインを続行するかどうかを決めます。

set -euo pipefail

RESULT_DIR="${PWD}/artifacts"
RESULT_BUNDLE="${RESULT_DIR}/FileProtection.xcresult"

rm -rf "${RESULT_BUNDLE}"
mkdir -p "${RESULT_DIR}"

xcodebuild test \
  -project App.xcodeproj \
  -scheme SecurityRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath "${RESULT_BUNDLE}" \
  -only-testing:AppSecurityTests/FileProtectionTests

デバイス名は、ノードにインストール済みのランタイムに合わせる必要があります。すべての環境が完全に同一だと仮定してはいけません。実行前に xcrun simctl list devices available を使って対象を確認できます。本番パイプラインでは、許可するデバイスとシステムバージョンを設定に明記し、対象が存在しない場合は、別の環境へ暗黙的に切り替えるのではなく、直ちに失敗させます。

失敗時には、少なくとも .xcresult、テストの標準出力、保護クラスの一覧を保存します。テストコンテナ内の機密サンプルをアーカイブしたり、調査を容易にするためだけにファイル本文を出力したりしてはいけません。証跡は「どのパスで、何を期待し、実際にはどうなっており、どのテスト手順で作成されたか」に答えるものであるべきで、業務データそのものを複製するものではありません。

シミュレータの限界とよくある誤判定に対処する

シミュレータは、コードが最終ファイルへ保護属性を設定しているかどうかを確認する用途には適しています。しかし、物理デバイスのロック後における鍵の利用可能状態を完全には再現できません。そのため、このCIゲートが担うのは属性の回帰検出です。ロック、再起動、バックグラウンド復帰時の実際のアクセス動作については、引き続き管理された実機検証フローで確認する必要があります。

もう1つの誤判定要因は、クリーンアップの順序です。データベースを閉じたり一時ディレクトリを削除したりした後で補助ファイルを確認すると、「ファイルが存在しない」という結果になります。しかし、それが安全なクリーンアップなのか、テストが正しく機能していないのかは区別できません。アサーションはライフサイクル上の明確な位置に置き、ファイルが存在しない場合はパスを含む失敗として報告します。

最後に、4種類の変更を確認します。書き込みAPIが変更されたか、ファイルが原子的に置換されるようになったか、データベースのモードが変わったか、サードパーティ製コンポーネントによって新しい永続化パスが追加されたかです。このうち1つでも変化した場合は、保護対象の一覧を見直す必要があります。このように構築したゲートは人の記憶に依存せず、「以前は正しく設定されていた」ことを「現在ディスク上にあるファイルも正しく保護されている」ことと取り違えません。

よくある質問

書き込み時のオプションだけを確認すれば十分ですか?

十分ではありません。置換処理や移行処理でファイルが再作成されるため、テスト後の実ファイルから保護クラスを読み取る必要があります。

シミュレータの検証だけで端末試験を省略できますか?

できません。シミュレータは属性設定の確認に向きますが、ロック後のアクセス可否は管理された実機で別途確認します。

VMMini M4

専有物理ノードで次のタスクを実行

ノードと課金期間を選択し、固定構成のM4、16GB RAM、256GB SSDでデプロイを開始できます。実際の利用可否はコンソールのリアルタイム表示をご確認ください。

クラウドMacを今すぐレンタル