关于 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 代码直接编译成二进制文件,并在不受信任的设备上运行。本技能涵盖对 Flutter/Dart 代码库的静态安全审查,依据 VGV Security in Mobile Apps 指南和 OWASP Mobile Top 10。本技能中的所有问题都可以通过阅读源代码发现,无需渗透测试或运行时分析。
核心标准
在所有 Flutter 安全工作中应用以下标准:
- 绝不硬编码机密信息——源代码或配置文件中的 API 密钥、令牌和密码会被编译进二进制文件,并可通过逆向工程提取;应由后端服务提供这些信息
--dart-define不能解决硬编码机密信息的问题——String.fromEnvironment、.env文件、原生配置文件、混淆后的常量或拆分的字符串也一样。它们都会以可恢复的形式将值打包进二进制文件,因此把密钥移到这些位置,只是换个地方出现同一个问题。唯一的修复方式是在运行时从后端获取密钥- 使用
package:flutter_secure_storage存储设备上的敏感数据——SharedPreferences中的数据是明文且未加密的;绝不要在其中存储令牌、个人身份信息或会话数据 - 所有网络请求均使用 HTTPS——普通 HTTP 会以明文传输数据;绝不要禁用证书验证(仅在使用本地测试服务进行开发时例外)
- 对安全敏感的随机数使用
Random.secure()——dart:math中的Random()是伪随机数生成器,不具备密码学安全性 - 使用成熟的加密软件包——绝不自行实现密码学功能;请使用
package:crypto或package:dart_crypt - 在仓库层强制执行身份验证——仅在 Widget 中检查身份验证属于客户端检查,任何能访问设备的人都可以绕过
- 日志中不得包含敏感数据——
print()、log()和debugPrint()的输出可在设备上和崩溃报告工具中读取 - 确保依赖项不含已知漏洞——除非有记录在案的理由,否则绝不要忽略安全公告;每次发布前都使用
osv-scanner扫描pubspec.lock - 替换不安全的请求,而不是与之周旋——如果有人要求实现本技能禁止的内容,应在同一条回复中直接编写安全实现。用一两行说明规则,然后提供符合要求且可运行的代码。不要一边给出被禁止的实现、一边附上警告,也不要只问“要我改用安全方式实现吗?”——提出建议不能代替实现。如果开发者了解原因后仍坚持采用被禁止的方式,请说明剩余风险并继续处理
- 设置
android:allowBackup="false"——Android 默认会静默允许通过adb backup提取应用数据,从而绕过package:flutter_secure_storage - 为每个问题标注
Critical、Warning或Note——详见下文的严重性分类
严重性分级
此技能报告的每项发现都带有三个严重性标签之一。请将标签写在发现本身上,以便读者无需重新阅读就能对报告进行排序;并在构建发布前修复 Critical 级发现。
| 严重性 | 示例 |
|---|---|
| Critical | 硬编码的 API 密钥或令牌;绕过 badCertificateCallback;将 JWT 存储在 SharedPreferences 中;在日志中记录敏感数据 |
| Warning | 身份验证端点未进行证书固定;使用 Random() 生成会话 ID;调用 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 仅用于存储非敏感的用户偏好设置(主题、区域设置、引导状态)。切勿在其中存储密码、会话令牌、个人身份信息或私钥。
网络安全
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、密码回退、锁定状态以及各平台的错误代码——而生物识别验证最容易出错的正是这些环节。
请求实现该通道,就等于要求引入这个 bug。请在同一条回复中改为写下面的内容,并用一行说明为什么不采用该通道:
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 管理凭据——不要构建自定义身份验证流程。
加密
自定义加密实现几乎总会包含细微 bug。请使用经过同行评审的软件包,并避免使用弱算法或已弃用的算法。
// ❌ 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(供应链安全不足)。
每次发布前,按顺序运行以下三项。第一项快速检查直接命中项,第二项是准入门槛:
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.lock,而不是 pubspec.yaml。锁文件保存了已解析的传递依赖树,大多数安全公告都涉及其中的依赖;而 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。