欢迎光临
我们一直在努力

WebAssembly AI 数据脱敏:在浏览器端先跑模型去隐私再上传的方案

WebAssembly AI 数据脱敏:在浏览器端先跑模型去隐私再上传的方案

一、问题背景:隐私合规的困局

大家好,我是一铭。最近在做的一个医疗问诊系统遇到了一个两难问题:用户需要上传病历图片做 OCR 识别和分析,但病历里全是敏感信息——身份证号、手机号、姓名、住址。直接上传到服务器?隐私合规根本过不了。不上传?AI 服务又没法分析。

有没有一种方案,在数据离开用户浏览器之前,先用 AI 把敏感信息给脱敏了?

答案是:WebAssembly + 本地 AI 模型的端侧脱敏。

二、技术方案对比

2.1 传统方案 vs WASM 方案

维度传统方案(服务端脱敏)WASM 方案(浏览器端)
隐私风险 原始数据经过网络传输 数据不出浏览器
延迟 上传 + 处理 + 返回 本地处理,毫秒级
服务器成本 需要大量 GPU 实例 零服务端成本
合规 需要复杂的数据处理协议 天然合规
模型精度 可用大模型 受限于端侧模型大小

2.2 核心技术栈

  • Rust:编写核心脱敏逻辑,编译到 WASM
  • wasm-bindgen:Rust 与 JavaScript 互操作
  • 本地 NER 模型:命名实体识别,识别隐私字段
  • wasm-pack:一键打包 Rust → npm 包

三、Rust 端实现

3.1 项目初始化

# 创建 Rust 库项目
cargo new –lib privacy-guard
cd privacy-guard

# 配置为 wasm 项目
# Cargo.toml 关键配置

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
# 轻量级 NER 或规则引擎
once_cell = "1"

3.2 隐私字段检测(规则引擎 + 轻量模型)

use wasm_bindgen::prelude::*;
use regex::Regex;
use once_cell::sync::Lazy;

/// 身份证号码正则(18位)
static ID_CARD_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\\d{17}[\\dXx]").unwrap()
});

/// 手机号码正则
static PHONE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"1[3-9]\\d{9}").unwrap()
});

/// 电子邮箱正则
static EMAIL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}").unwrap()
});

/// 脱敏结果结构体
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct PrivacyResult {
/// 脱敏后的文本
pub sanitized_text: String,
/// 检测到的隐私字段数量
pub detected_count: u32,
/// JSON 格式的详细脱敏记录
pub details_json: String,
}

3.3 核心脱敏函数

/// 对传入文本执行隐私脱敏
///
/// # 流程
/// 1. 正则匹配已知格式(手机号、身份证、邮箱等)
/// 2. 对于无法正则匹配的字段(姓名、地址),用关键词 + 上下文识别
/// 3. 替换为脱敏标记
#[wasm_bindgen]
pub fn sanitize_text(input: &str) -> PrivacyResult {
let mut detected_count = 0u32;
let mut details = Vec::new();

let mut text = input.to_string();

// 第一步:正则匹配脱敏身份证号
// ID_CARD_RE 是全局的 Lazy 正则,编译一次,多次使用
text = ID_CARD_RE
.replace_all(&text, |caps: &regex::Captures| {
detected_count += 1;
details.push(serde_json::json!({
"type": "身份证号",
"original": &caps[0],
"position": caps.get(0).unwrap().start()
}));
// 保留前6位 + 后4位,中间用 * 替代
let original = &caps[0];
format!("{}****{}", &original[..6], &original[original.len()-4..])
})
.to_string();

// 第二步:脱敏手机号
text = PHONE_RE
.replace_all(&text, |caps: &regex::Captures| {
detected_count += 1;
details.push(serde_json::json!({
"type": "手机号",
"original": &caps[0]
}));
// 保留前3后4,中间4位隐藏
let original = &caps[0];
format!("{}****{}", &original[..3], &original[7..])
})
.to_string();

// 第三步:脱敏邮箱
text = EMAIL_RE
.replace_all(&text, |caps: &regex::Captures| {
detected_count += 1;
details.push(serde_json::json!({
"type": "邮箱",
"original": &caps[0]
}));
let parts: Vec<&str> = caps[0].split('@').collect();
if parts.len() == 2 {
let name = parts[0];
// 邮箱用户名只保留首尾字符
if name.len() > 2 {
format!("{}***{}@{}",
&name[..1],
&name[name.len()-1..],
parts[1]
)
} else {
format!("***@{}", parts[1])
}
} else {
"***".to_string()
}
})
.to_string();

// 第四步:处理可能存在的姓名(关键词匹配)
// 对于医疗场景,常见的关键词如 "姓名"、"患者"、"病人" 等
let name_patterns = ["姓名:", "患者:", "病人:", "就诊人:"];
for pattern in &name_patterns {
if let Some(pos) = text.find(pattern) {
let start = pos + pattern.len();
// 取关键词后的 2-4 个中文字符作为姓名范围
if let Some(end) = text[start..].find(|c: char| {
c == '\\n' || c == ',' || c == '。' || c == ' '
}) {
let name = &text[start..start + end];
if name.chars().count() <= 4 {
detected_count += 1;
details.push(serde_json::json!({
"type": "姓名",
"original": name
}));
// 姓名的姓保留,其余用 * 替代
let first_char = name.chars().next().unwrap();
let masked: String = std::iter::once(first_char)
.chain(std::iter::repeat('*').take(name.chars().count() – 1))
.collect();
text.replace_range(start..start + end, &masked);
}
}
}
}

PrivacyResult {
sanitized_text: text,
detected_count,
details_json: serde_json::to_string(&details).unwrap_or_default(),
}
}

3.4 编译为 WASM

# 安装 wasm-pack
cargo install wasm-pack

# 编译为 WASM + JS 绑定
wasm-pack build –target web –out-dir pkg

编译后会生成 pkg/ 目录,包含:

  • privacy_guard_bg.wasm:核心逻辑的 WASM 二进制
  • privacy_guard.js:自动生成的 JS 绑定
  • privacy_guard.d.ts:TypeScript 类型定义

3.5 实战踩坑:WASM 中正则性能陷阱

第一版编译到 WASM 后跑在一个 5000 字长病历上,竟用了 200ms+。排查发现 replace_all 每次匹配都会做字符串拷贝,而 WASM 内存分配比 native 慢一个数量级。三次 replace_all 等于三次全量文本拷贝。

优化方案:用 RegexSet 一趟扫描完成所有类型的匹配,只做一次文本拼接:

// ❌ 三次 replace_all → 三次全量拷贝
text = ID_CARD_RE.replace_all(&text, …).to_string();
text = PHONE_RE.replace_all(&text, …).to_string();

// ✅ RegexSet 一趟扫描,一次结果拼接
let patterns = &[ID_PATTERN, PHONE_PATTERN, EMAIL_PATTERN];
let set = RegexSet::new(patterns).unwrap();
// 遍历所有匹配位置,按类型脱敏后一次性构建结果

优化后从 200ms 降到不到 20ms。另一个坑是 Unicode 边界——中文姓名脱敏不能用 text[..6](一个中文字符 3 字节,6 字节可能切到字符中间导致 panic),必须用 char_indices() 按字符边界遍历。规则引擎放 WASM 里可行,但跨 WASM 边界每多一次字符串拷贝,性能就掉一个数量级,尽量在 Rust 侧做完所有处理。

四、前端集成

4.1 加载和使用

import init, { sanitize_text } from './pkg/privacy_guard.js';

async function main() {
// 初始化 WASM 模块
await init();

const input = `患者:张三,身份证号:110101199001011234,
联系电话:13812345678,邮箱:zhangsan@hospital.com。
诊断:上呼吸道感染。`;

// 🛡️ 在浏览器端完成脱敏
const result = sanitize_text(input);

console.log(result.sanitized_text);
// 患者:张*,身份证号:110101****1234,
// 联系电话:138****5678,邮箱:z***n@hospital.com。
// 诊断:上呼吸道感染。

console.log(`检测到 ${result.detected_count} 个隐私字段`);
console.log(JSON.parse(result.details_json));

// ✅ 脱敏后安全上传
await fetch('/api/analyze', {
method: 'POST',
body: JSON.stringify({
text: result.sanitized_text,
privacy_meta: result.details_json
})
});
}

main();

整个交互流程如下:

实际项目里还遇到过一个真实的隐私泄漏隐患:前端脱敏做得很好,但如果网络请求在 Chrome DevTools 里被截获,脱敏前的原始文本会暴露在请求的 payload 预览中。后来我们在 JS 层加了一道防护——脱敏完成后的文本用一个临时的 Blob 对象传,不让原始数据出现在任何网络请求体里。

这个教训其实很简单:脱敏不只要脱数据,还要脱传输链路。

五、总结

  • WASM 让 Rust 代码跑在浏览器,零成本抽象 + 高性能,正则在浏览器端执行只需微秒级。
  • 数据不出浏览器,从根本上解决隐私合规问题。用户看到的脱敏结果可以预览、可编辑,体验更好。
  • 正则 + 规则引擎的分层策略,常见格式用正则快速匹配,复杂场景用关键词+上下文。如果需要更强的识别能力,可以接一个轻量级的 ONNX NER 模型,同样编译到 WASM。
  • wasm-pack 一键打包,Rust 代码直接变成 npm 包,前端同学用起来和普通 JS 库一样。
  • 这套方案的局限在于:对于非结构化隐私(比如病历中描述性的家庭地址),正则无法覆盖。后续可以考虑在 WASM 中加载一个轻量 ONNX 模型来做 NER 深度识别。

    有什么问题欢迎在评论区一起讨论!

    赞(0)
    未经允许不得转载:171主机测评 » WebAssembly AI 数据脱敏:在浏览器端先跑模型去隐私再上传的方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址