import 'package:flutter/foundation.dart'; import 'package:sunny_mochi/core/config/env.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; /// 包装 [SentryFlutter.init],统一注入: /// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底) /// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5) /// - 屏蔽 screenshot / view hierarchy(含敏感页面) /// /// 调用方在 main.dart 包一层: /// ```dart /// await SentrySetup.init(appRunner: () => runApp(...)); /// ``` class SentrySetup { static Future init({required Future Function() appRunner}) async { if (Env.sentryDsn.isEmpty) { // P0 / 本地开发兜底:未配置 DSN 时直接跑应用,不绕道 Sentry init if (kDebugMode) { debugPrint('[Sentry] DSN 为空,跳过 init(仅本地,生产必须配置)'); } await appRunner(); return; } await SentryFlutter.init( (options) { options ..dsn = Env.sentryDsn ..environment = Env.name ..tracesSampleRate = Env.isProd ? 0.2 : 1.0 ..debug = kDebugMode // === 健康数据 PII 脱敏(强约束)=== ..sendDefaultPii = false ..beforeSend = scrubPii; }, appRunner: appRunner, ); } /// 丢弃可能含敏感数据的字段,仅保留错误归因所需的元信息。 /// /// 脱敏覆盖: /// - `request.data` / `request.cookies`(可能含登录密码 / token / 手机号) /// - `request.headers` 仅保留白名单 /// - `user.email` / `user.ipAddress`(仅保留匿名 userId) /// - `breadcrumbs.message` 内 token / 手机号 / 身份证 / 长 base64 串掩盖 /// /// **可见**:visible for testing — 直接调用以验证脱敏行为。 @visibleForTesting static SentryEvent? scrubPii(SentryEvent event, Hint hint) { // Sentry 9.x:直接 mutate 字段(copyWith 已 deprecated)。 event ..request = _scrubRequest(event.request) ..user = _scrubUser(event.user) ..breadcrumbs = _scrubBreadcrumbs(event.breadcrumbs) // R-SEC-3 / 2026-05-10:extra 字段防御性清理 — // SDK 自动 instrumentation(frame tracking 等)可能写入 Widget 名称 / 路由参数, // 路由参数可能含手机号 / userId。用白名单过滤而非全清,保留调试上下文。 ..extra = _scrubExtra(event.extra); return event; } static SentryRequest? _scrubRequest(SentryRequest? request) { if (request == null) return null; return SentryRequest( url: request.url, method: request.method, // queryString / cookies / data 强制清空(可能含 token / 手机号 / 密码 / 健康数据) headers: _scrubHeaders(request.headers), apiTarget: request.apiTarget, ); } static SentryUser? _scrubUser(SentryUser? user) { if (user == null) return null; // 仅保留匿名 userId(要求上层调用方已哈希);email/IP 强制清空 return SentryUser(id: user.id); } /// 仅保留非敏感 header(白名单策略)。 static Map? _scrubHeaders(Map? headers) { if (headers == null) return null; const allow = {'content-type', 'accept', 'user-agent', 'x-request-id'}; return { for (final e in headers.entries) if (allow.contains(e.key.toLowerCase())) e.key: e.value, }; } /// 把 breadcrumbs 中 message 内的 token / 手机号 / 身份证 / 长 base64 串掩盖。 static List? _scrubBreadcrumbs(List? crumbs) { if (crumbs == null) return null; return crumbs .map( (c) => Breadcrumb( timestamp: c.timestamp, message: c.message == null ? null : maskSensitive(c.message!), category: c.category, level: c.level, type: c.type, // data 中也可能含敏感字段,按白名单清空(仅保留 method/status_code/url 这种) data: _scrubBreadcrumbData(c.data), ), ) .toList(); } static Map? _scrubBreadcrumbData(Map? data) { if (data == null) return null; const allow = {'method', 'status_code', 'url', 'reason'}; return { for (final e in data.entries) if (allow.contains(e.key)) e.key: e.value is String ? maskSensitive(e.value as String) : e.value, }; } /// 仅保留非敏感 extra(白名单 + 字符串值掩盖)。 /// /// 业务侧 `Sentry.captureException(extra: {...})` 应主动避免传 PII; /// 此函数作为防御层,覆盖 SDK 自动写入场景。 static Map? _scrubExtra(Map? extra) { if (extra == null) return null; // 路由 / Widget 上下文是调试有用且非 PII 的元信息,保留; // 其他业务自定义字段全部清除(业务方明确知道字段含义时应在 captureException 时 // 自行 mask 后放进来,再加进 allow 列表)。 const allow = {'route', 'route_name', 'widget', 'screen', 'flavor'}; return { for (final e in extra.entries) if (allow.contains(e.key)) e.key: e.value is String ? maskSensitive(e.value as String) : e.value, }; } /// 把字符串中的常见敏感模式替换为 `***`。 /// /// 模式: /// - 手机号:11 位 1 开头数字 /// - 身份证:18 位(最后一位可 X) /// - JWT / 长 base64 token:连续 32+ 个 base64url 字符 /// - 形如 `Bearer xxx` / `token=xxx` 的整段 @visibleForTesting static String maskSensitive(String input) { return input // Bearer / Authorization-style .replaceAll( RegExp( r'(?:bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9._\-+/=]{12,}', caseSensitive: false, ), '***', ) // 长 base64 / JWT .replaceAll(RegExp(r'[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}'), '***') // 中国大陆手机号 .replaceAll(RegExp(r'(?