Template
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值 P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表 P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
161 lines
6.1 KiB
Dart
161 lines
6.1 KiB
Dart
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 跳过实际上报)
|
||
/// - PII 脱敏(屏蔽手机号 / token / 身份证等敏感数据)
|
||
/// - 屏蔽 screenshot / view hierarchy
|
||
///
|
||
/// 调用方在 main.dart 包一层:
|
||
/// ```dart
|
||
/// await SentrySetup.init(appRunner: () => runApp(...));
|
||
/// ```
|
||
class SentrySetup {
|
||
static Future<void> init({required Future<void> 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.isRelease ? 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<String, String>? _scrubHeaders(Map<String, String>? 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<Breadcrumb>? _scrubBreadcrumbs(List<Breadcrumb>? 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<String, dynamic>? _scrubBreadcrumbData(Map<String, dynamic>? 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<String, dynamic>? _scrubExtra(Map<String, dynamic>? 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'(?<!\d)1[3-9]\d{9}(?!\d)'), '***')
|
||
// 身份证号
|
||
.replaceAll(RegExp(r'(?<!\d)\d{17}[\dXx](?!\d)'), '***');
|
||
}
|
||
}
|