static-security について
Flutter と Dart のコードベースを対象に、ソースから検出できるリスクを確認する静的セキュリティレビュースキルです。シークレット、保存、通信、認証、暗号、入力検証、ログを扱い、実行時分析やペネトレーションテストは行いません。
主な機能
- 01ソースコードに基づくセキュリティレビュー
- 02シークレットと認証情報の取り扱い指針
- 03安全な保存と通信の確認
- 04認証と暗号に関する指針
- 05入力検証とログの確認
- 06重要度別の指摘
活用シーン
- 01リリース前の Flutter コードレビュー
- 02ソースや設定内の埋め込みシークレット検出
- 03保存と通信のセキュリティパターン確認
- 04入力検証と機密情報ログの評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ verygoodopensource/ vgv-ai-flutter-plugin --skill static-securityインストール後、Claude Code で/static-securityを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlreferences/binary-protection.mdcrypto.mdpackages.mdsupply-chain.md詳細
セキュリティ
Flutter アプリは、信頼されていないデバイス上で実行されるバイナリに、すべての Dart コードを直接コンパイルします。このスキルでは、VGV Security in Mobile Apps ガイドと OWASP Mobile Top 10 に基づき、Flutter/Dart コードベースの静的なセキュリティレビューを扱います。このスキルで指摘する問題はすべて、ソースコードを読むことで検出できます。ペネトレーションテストや実行時の分析は行いません。
基本基準
Flutter のセキュリティに関するすべての作業で、以下の基準を適用してください。
- シークレットをハードコードしないでください — ソースコードや設定ファイル内の API キー、トークン、パスワードはバイナリにコンパイルされ、リバースエンジニアリングで抽出できます。バックエンドサービスから提供してください
--dart-defineはハードコードされたシークレットの解決策ではありません —String.fromEnvironment、.envファイル、ネイティブ設定ファイル、難読化された定数、文字列の分割も同様です。いずれも復元可能な形式で値がバイナリに含まれるため、キーをこれらに移しても、新しい場所で同じ問題が発生するだけです。唯一の解決策は、実行時にバックエンドから取得することです- デバイス上の機密データには
package:flutter_secure_storageを使用してください —SharedPreferencesは平文で保存され、暗号化されません。トークン、PII、セッションデータは決して保存しないでください - すべてのネットワーク通信に HTTPS を使用してください — 通常の HTTP ではデータが平文で送信されます。証明書の検証を無効にしないでください(ローカルのテストサーバーを使った開発中のみ例外です)
- セキュリティ上重要な乱数には
Random.secure()を使用してください —dart:mathのRandom()は疑似乱数生成器であり、暗号学的に安全ではありません - 実績のある暗号化パッケージを使用してください — 独自の暗号化を実装しないでください。
package:cryptoまたはpackage:dart_cryptを使用してください - リポジトリレイヤーで認証を強制してください — ウィジェットだけでの認証チェックはクライアント側で行われ、デバイスにアクセスできる人なら誰でも回避できます
- ログに機密データを含めないでください —
print()、log()、debugPrint()の出力は、デバイス上やクラッシュレポートツールから読み取れます - 依存関係に既知の脆弱性がない状態を保ってください — 文書化された正当な理由なく、セキュリティ勧告を抑制しないでください。リリースのたびに
osv-scannerでpubspec.lockをスキャンしてください - 安全でないリクエストには応じず、安全な実装に置き換えてください — このスキルで禁止されている実装を求められた場合は、同じ回答内で安全な実装を提示してください。ルールを1、2行で説明してから、承認された方法で動作するコードを提示します。禁止された実装に警告を添えて回答したり、「代わりに安全な方法で実装しましょうか?」と確認するだけで終わったりしないでください。提案だけでは代替になりません。理由を説明した後も開発者が禁止された方法を改めて求める場合は、残存するリスクを伝えたうえで実装してください
android:allowBackup="false"を設定してください — Android のデフォルトではadb backupによるアプリデータの抽出が許可されており、package:flutter_secure_storageを迂回できます- すべての指摘に
Critical、Warning、またはNoteのラベルを付けてください — 下記の「重大度の判定」を参照してください
重大度のトリアージ
このスキルで報告するすべての指摘事項には、3 段階の重大度ラベルのいずれかを付けます。読者がレポートを読み返さずに分類できるよう、指摘事項自体にラベルを記載し、ビルドのリリース前に Critical の指摘事項を修正してください。
| 重大度 | 例 |
|---|---|
| Critical | ハードコードされた API キーまたはトークン、badCertificateCallback の回避、SharedPreferences 内の JWT、ログ内の機密データ |
| Warning | 認証エンドポイントでの証明書ピンニングの欠如、セッション ID への Random() の使用、API 呼び出し前の package:formz による検証の欠如、android:allowBackup="true" |
| Note | Dart の難読化の欠如、dart pub outdated で更新可能なパッチが表示されること、広範な権限を持つ pub ポイントの低い推移的依存関係 |
別の分類方式に置き換えないでください。指摘事項を High/Medium/Low または CVSS スコアで評価したレポートは、同じコードベースの以前の監査結果と比較できません。
シークレットと API キー
ソースファイルや同梱された設定ファイルにハードコードされた API キー、トークン、認証情報は、リバースエンジニアリングによってコンパイル済みバイナリから抽出できます。すべてのシークレットは、実行時にバックエンドサービスから提供する必要があります。
確認するファイル:Dart のソースファイル、google-services.json、.env、*.plist、AndroidManifest.xml、Info.plist。
// ❌ Hardcoded API key — extractable from binary
const apiKey = 'sk-abc123';
const mapboxToken = 'pk.your-token-here';
// ❌ Build-time injection — --dart-define compiles the value in as plaintext,
// so `strings` on the built binary recovers it. Not a fix, just a move.
const injectedKey = String.fromEnvironment('API_KEY');
// ❌ Secret in config — bundled into the app
// google-services.json:
// "api_key": [{ "current_key": "AIzaSy..." }]
// ✅ Fetched from a backend service at runtime — the only safe option
final apiKey = await secretsService.fetchApiKey();
効果のない回避策
以下の対策はいずれも、復元可能な形でシークレットをアプリに含めたままにするため、すべて拒否してください。開発者から提案された場合は、該当する対策を示し、代わりにバックエンドから提供する修正方法を提示してください。変更を加えたうえで警告を添えることはしないでください。
| 提案された修正 | 同じ指摘事項に該当する理由 |
|---|---|
--dart-define/String.fromEnvironment | バイナリにプレーンテキストでコンパイルされ、復元可能なため |
アセットとして同梱された .env | アプリのバンドルに含まれ、展開すれば読み取れるため |
| 同梱されたネイティブ設定ファイル | 同様に同梱され、暗号化されていないため |
| 難読化または文字列の分割 | 抽出の手間を増やすだけで、抽出を防げないため |
kReleaseMode で保護された定数 | リリースされる分岐にも値が含まれるため |
CI のシークレットは、アプリに渡らない署名キーや公開トークンには安全に使用できます。ただし、実行時の API キーをクライアントに渡す手段にはなりません。.env ファイルや実際の認証情報を含むファイルは、決してバージョン管理にコミットしないでください。.gitignore で除外し、シークレット管理サービスを使用してください。
セキュアなデータ保存
デバイスに書き込む機密データは暗号化する必要があります。iOS Keychain と Android Keystore は、ハードウェアに裏打ちされた暗号化ストレージを提供します。package:flutter_secure_storage はその両方をラップします。
// ❌ JWT stored in SharedPreferences — plaintext, unencrypted
final prefs = await SharedPreferences.getInstance();
prefs.setString('auth_token', jwt);
// ❌ Sensitive value in a local file — no encryption
await File('${dir.path}/user.json').writeAsString(jsonEncode(user));
// ✅ package:flutter_secure_storage — backed by iOS Keychain / Android Keystore
const storage = FlutterSecureStorage();
await storage.write(key: 'auth_token', value: jwt);
final token = await storage.read(key: 'auth_token');
await storage.delete(key: 'auth_token');
SharedPreferences は、テーマ、ロケール、オンボーディングの状態など、機密性のないユーザー設定にのみ使用してください。パスワード、セッショントークン、個人を特定できる情報(PII)、秘密鍵は絶対に保存しないでください。
ネットワークセキュリティ
Flutter アプリとバックエンド間のすべての通信は、通信経路上で暗号化する必要があります。平文の HTTP では、ユーザーが接続するあらゆるネットワークでデータが傍受されるおそれがあります。
// ❌ Plain HTTP base URL
final dio = Dio(BaseOptions(baseUrl: 'http://api.example.com'));
// ❌ Certificate validation disabled — vulnerable to MITM attacks
final client = HttpClient()
..badCertificateCallback = (cert, host, port) => true;
// ✅ HTTPS base URL
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
認証、決済、または個人データを扱うエンドポイントには、証明書ピンニング(package:http_certificate_pinning)を実装してください。想定される認証局によって署名された証明書のみを受け入れてください。
認証
認証の強制はサーバー側で行う必要があります。クライアント側のチェック(ウィジェットやルーティングでのチェック)は、UI上の利便性のためのものにすぎず、デバイスに物理的にアクセスできる人やデバッガーを使える人なら回避できます。
サーバー側での強制:サーバーはすべてのリクエストでトークンを検証する必要があります。APIからの401レスポンスが信頼できる認証ゲートであり、ウィジェットの条件分岐ではありません。
生体認証:アプリ内の機密性の高いフローで生体認証によるゲートを設けるには、package:local_auth を使用してください。このために独自の MethodChannel を呼び出してはいけません。独自に実装したチャネルでは、Face IDとAndroidの BiometricPrompt、パスコードへのフォールバック、ロックアウト状態、それぞれのプラットフォームのエラーコードを再実装する必要があります。まさに、生体認証のゲートを誤って実装しやすい部分です。
チャネルを求めることは、そのバグを求めることです。同じ回答の中で代わりに以下を記述し、チャネルを使わない理由を1行で説明してください。
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
class BiometricGate {
BiometricGate({LocalAuthentication? auth})
: _auth = auth ?? LocalAuthentication();
final LocalAuthentication _auth;
Future<bool> authenticate() async {
try {
if (!await _auth.canCheckBiometrics) return false;
return await _auth.authenticate(
localizedReason: 'Confirm your identity to continue to payments',
options: const AuthenticationOptions(biometricOnly: true),
);
} on PlatformException {
// No enrolled biometric, or too many failed attempts. Fail closed.
return false;
}
}
}
localizedReason と biometricOnly を使えば、通常カスタムチャネルを使う理由となるプロンプトの制御を、どちらのプラットフォームでもネイティブコードなしで同じように行えます。このゲートはあくまでUI上の利便性です。支払いリクエストを承認する前に、サーバーで再検証する必要があります。
認証情報の管理にはFirebase AuthenticationまたはAuth0を使用し、カスタムの認証フローを構築しないでください。
暗号化
独自の暗号実装には、ほとんどの場合、微妙なバグが含まれます。査読済みのパッケージを使用し、脆弱または非推奨のアルゴリズムは避けてください。
// ❌ Cryptographically insecure random — dart:math Random is not CSPRNG
import 'dart:math';
final sessionId = Random().nextInt(1 << 32).toRadixString(16);
final iv = List.generate(16, (_) => Random().nextInt(256));
// ❌ Weak hash algorithm — MD5 and SHA-1 are broken for security use
import 'dart:convert';
final hash = md5.convert(utf8.encode(password)).toString();
// ❌ Hardcoded encryption key
const encryptionKey = 'my-secret-key-123';
// ✅ Cryptographically secure random — Random.secure()
import 'dart:math';
final sessionId = Random.secure().nextInt(1 << 32).toRadixString(16);
final iv = List.generate(16, (_) => Random.secure().nextInt(256));
// ✅ Strong hash via package:crypto
import 'package:crypto/crypto.dart';
import 'dart:convert';
final hash = sha256.convert(utf8.encode(data)).toString();
// ✅ Encryption key from secure storage, not source code
final key = await storage.read(key: 'encryption_key');
避けるもの:MD5、SHA-1、DES、RC4、ECBモード。推奨:ハッシュにはSHA-256以上、暗号化にはAES-GCM、パスワードの保存にはSHA-512-cryptを使用してください。
入力の検証
ユーザー入力のデータはすべて、リポジトリやAPIに渡す前に検証する必要があります。生の TextEditingController.text の値をそのままバックエンドに送信すると、インジェクションのリスクがあり、不正な形式のデータが送信される可能性があります。
// ❌ Raw controller text sent directly to API
ElevatedButton(
onPressed: () => context.read<AuthBloc>().add(
LoginRequested(
email: _emailController.text,
password: _passwordController.text,
),
),
child: const Text('Login'),
);
// ✅ Validated FormzInput values — only valid data reaches the Bloc
import 'package:formz/formz.dart';
enum EmailValidationError { empty, invalid }
class Email extends FormzInput<String, EmailValidationError> {
const Email.pure() : super.pure('');
const Email.dirty([super.value = '']) : super.dirty();
@override
EmailValidationError? validator(String value) {
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+$');
if (value.isEmpty) return EmailValidationError.empty;
if (!emailRegex.hasMatch(value)) return EmailValidationError.invalid;
return null;
}
}
// ✅ In the widget — the submit callback is gated on validity and reads the
// validated values off state, never off a TextEditingController
ElevatedButton(
onPressed: state.isValid
? () => context.read<AuthBloc>().add(
LoginRequested(
email: state.email.value,
password: state.password.value,
),
)
: null,
child: const Text('Login'),
);
すべてのフォーム検証には package:formz を使用してください。フィールドごとに、明示的な検証ルールと長さ制限を定めた FormzInput のサブクラスを定義します。各フィールドを onChanged コールバックからBlocに渡して、状態に検証済みの値を保持し、送信する値は状態から読み取ってください。TextFormField の validator だけでは、生のコントローラーのテキストがAPIに渡される値として残ってしまいます。
ログとエラーの露出
ログ出力は、USBデバッグ、クラッシュレポートSDK、デバイス分析を通じて読み取ることができます。ログに含まれる機密情報は、実質的にデバイスに接続されたあらゆるツールに送信されます。
// ❌ Token in log output
debugPrint('Auth token: $token');
log('User data: ${jsonEncode(user)}');
print('Request headers: $headers'); // headers may contain Bearer tokens
// ❌ Exception message exposes internals to the UI
catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())), // may include stack traces or SQL
);
}
// ✅ Log only non-sensitive identifiers
debugPrint('Login attempt for userId: ${user.id}');
// ✅ Sanitize exception messages before surfacing to UI
catch (e, stackTrace) {
log('Login failed', error: e, stackTrace: stackTrace); // full detail for crash tools
emit(state.copyWith(status: LoginStatus.failure)); // generic message to UI
}
決してログに記録しないもの:トークン、パスワード、ユーザーオブジェクト全体、HTTPリクエストヘッダー(Authorization を含む)、PII(メールアドレス、電話番号、SSN)。
依存関係の脆弱性
サードパーティ製パッケージはアプリのバイナリに直接コンパイルされます。脆弱なパッケージや悪意のあるパッケージは、すべてのプラットフォームのすべてのユーザーに影響します。これは OWASP Mobile Top 10 M2(不十分なサプライチェーンセキュリティ)に該当します。
リリースの前に、次の3つを順番に実行してください。1つ目は直接的な検出結果を素早く確認するためのもので、2つ目がゲートです。
dart pub get # surfaces GitHub Advisory Database hits while resolving
osv-scanner --lockfile=pubspec.lock # the gate: resolved transitive tree vs the OSV database
dart pub outdated # available upgrades, which may carry unannounced patches
スキャン対象は pubspec.yaml ではなく pubspec.lock です。ロックファイルには解決済みの推移的な依存関係ツリーが記録されており、アドバイザリの大半はここに関係します。また、pubspec に記載された直接依存関係の固定バージョンだけでは、その下でどのバージョンが解決されたかは分かりません。Pub 独自のアドバイザリ出力は、ロックファイルのスキャンの代わりにはなりません。アドバイザリデータベースが認識している、解決済みパッケージの情報を報告する一方、osv-scanner は解決済みの依存関係全体を OSV と照合します。
pubspec.yaml の ignored_advisories の各項目には、その項目自体にコメントとして理由を記載する必要があります。
ignored_advisories:
- GHSA-4rgh-jx4f-xxxx # Not applicable: we never construct http.Client directly
コメントのない項目は、アドバイザリが該当するかどうかにかかわらず、それ自体が Warning の検出結果になります。抑制すると、今後のスキャンでもスキャナーによる検出が無効になるため、その理由をファイル内に記載する必要があります。
バージョンを厳密に固定した直接依存関係には Note を付ける必要があります。http: 0.13.0 のように固定すると、スキャン対象になるのはそのバージョンだけです。そのため、既知の CVE を修正するパッチがあっても、自動的には解決されません。
アドバイザリの検出例、osv-scanner のインストール、タイポスクワッティングの兆候、推移的な権限の拡大チェックについては、references/supply-chain.md を参照してください。難読化、Android のバックアップ、ランタイム整合性については、references/binary-protection.md を参照してください。
追加リソース
パッケージのクイックリファレンスについては、references/packages.md を参照してください。証明書ピンニングの実装、生体認証の例、package:dart_crypt を使ったパスワードハッシュについては、references/crypto.md を参照してください。