跳到主要内容

数据截至 (上游 commit 2689884a6257)

隐私脱敏

这一章讲什么: 一旦你开始录屏,你就在录别人的病历号、银行卡号和聊天记录。这一章讲 legacy 里那套脱敏子系统:抽象怎么分层、一个很容易忽略的「传染」规则、以及为什么它选择先复制整份录制。


1. 它要解决的小问题

一次桌面录制会同时产生四种载体的敏感信息:

载体例子怎么擦
文本键入的密码、病历号命名实体识别后替换
图像截图上显示的身份证图像 OCR + 涂黑
视频整段录屏逐帧处理
无障碍树控件的 AXValue 里躺着完整地址递归遍历字典

最后一行最容易被忘掉:无障碍树里的文本量常常比截图上看到的还多。


2. 抽象分三层

Modality ← 能力标签:TEXT / PIL_IMAGE / PDF / MP4

ScrubbingProvider (pydantic) ← 契约:scrub_text / scrub_image / scrub_mp4
│ capabilities: List[str]

├── TextScrubbingMixin ← 复用逻辑:递归 dict/list 打码

└── 具体提供方
PresidioScrubbingProvider (本地 spaCy + Presidio)
PrivateAIScrubbingProvider (外部 API)
ComprehendScrubbingProvider (AWS,已被从选项里注释掉)
  • 能力标签 Modality 是一个纯常量类,四个成员:TEXT / PIL_IMAGE / PDF / MP4(legacy/openadapt/privacy/base.py:11-17)。
  • 契约在 legacy/openadapt/privacy/base.py:20(ScrubbingProvider),未实现的方法一律 raise NotImplementedError
  • 基类上的 capabilities 类型是 List[str](privacy/base.py:24),收窄成 List[Modality] 的是各个子类——例如 Presidio 声明支持 TEXTPIL_IMAGE(privacy/providers/presidio.py:32)。
  • 工厂在 privacy/providers/__init__.py:7(ScrubProvider),get_scrubber 按名字惰性 import 具体实现(:30-51)——因为 Presidio 会拖进 spaCy 和 transformers。

AWS Comprehend 被从可选项里注释掉了,理由写在旁边:「Comprehend 不支持 scrub_image 方法」(privacy/providers/__init__.py:19-21)。能力声明和实际可选项不一致时,选择了直接摘掉而不是在运行时报错。


3. 核心机制:递归打码与「传染」规则

它要解决的小问题

无障碍树是任意深度嵌套的字典和列表。要在里面找到敏感文本,只能递归走一遍。

但有个陷阱:假设 text 字段是 "张三",被识别成人名打掉了。那么同一层里的 AXTitleAXValuedescription 很可能也是这个人名——只是措辞不同,NER 认不出来。

思路:一处命中,整片强擦

scrub_dict(privacy/base.py:107-162)里这几行是全章最关键的逻辑:

# 真实源码节选,legacy/openadapt/privacy/base.py:132-138
if self._should_scrub_text(key, value, list_keys, scrub_all):
scrubbed_text = self._scrub_text_item(value, key, force_scrub_children)
if key in ("text", "canonical_text") and self._is_scrubbed(value, scrubbed_text):
force_scrub_children = True
scrubbed_dict[key] = scrubbed_text

翻译成人话:只要 textcanonical_text 真的被改动过,就把 force_scrub_children 打开。

打开之后,后续所有子项走的不再是「智能识别」,而是 scrub_text_all——整串字符逐个换成 *(privacy/base.py:96-105,字符由 config.SCRUB_CHAR 决定,默认 "*")。

发现 text 字段被打码了


force_scrub_children = True

├─→ 该层后续的字符串 ──→ 全字符替换(不再靠 NER 判断)
└─→ 递归进子 dict/list 时把这个标志带下去

妙在哪: 这是一个宁可多擦不可少擦的启发式。NER 会漏,但「同一个控件的其他字段大概率是同一个东西的不同写法」这个假设成立率很高。

还有一条更粗暴的规则

# 真实源码节选,legacy/openadapt/privacy/base.py:152-156
if isinstance(key, str) and key == "state":
scrubbed_dict[key] = self.scrub_dict(value, list_keys, scrub_all=True)

只要 key 叫 state,里面所有字符串一律进入打码流程,不管在不在 list_keys 白名单里。state 正是 WindowEvent 存整棵无障碍树的字段(models.py:629 起)。因为那棵树的 key 完全由应用决定,列不完,所以整片当敏感处理。

默认白名单是 config.SCRUB_KEYS_HTML(privacy/base.py:128)。


4. 编排:为什么先复制整份录制

流程

scrub(recording_id, provider_id)

├─ 抢数据库写锁,抢不到直接失败
├─ crud.copy_recording ← 整份复制出一个新录制
├─ insert_scrubbed_recording ← 登记「这份是某提供方脱敏的产物」
└─ 5 个 worker 并发处理三个队列:
动作事件队列(只放顶层事件,parent_id 非空的跳过)
截图队列
窗口事件队列

实现在 legacy/openadapt/scrub.py:114-295(scrub),复制在 legacy/openadapt/db/crud.py:815(copy_recording),单项脱敏在 crud.py:876(scrub_item)。

三个设计决定

一、原件不动。 脱敏发生在副本上,原始录制保持原样。代价是磁盘翻倍,收益是脱敏出错可以重来。Recording 上的 original_recording_id 自引用外键(models.py:63-71)记录这层血缘,ScrubbedRecording 表(models.py:1145)登记用了哪个提供方。

二、只处理顶层动作事件。

# 真实源码节选,legacy/openadapt/scrub.py:189-192
for action_event in new_recording.action_events:
if action_event.parent_id is not None:
continue
action_event_q.put(action_event.id)

子事件跳过——因为父事件的 scrub 方法会连带处理(见 ActionEvent.scrub,models.py:490-498)。这里再一次用到了 03 章的父子树。

三、脱敏结果单独存字段,不覆盖原文。 ActionEventscrubbed_textscrubbed_canonical_text 两个独立列(models.py:169-170)。


5. Presidio 提供方的一个性能陷阱

PresidioScrubbingProvider.scrub_text(privacy/providers/presidio.py:41-)每次调用都在函数体内部重建整条 NLP 流水线:

# 真实源码节选,legacy/openadapt/privacy/providers/presidio.py:54-60
SCRUB_PROVIDER_TRF = NlpEngineProvider(nlp_configuration=config.SCRUB_CONFIG_TRF)
NLP_ENGINE_TRF = SCRUB_PROVIDER_TRF.create_engine()
ANALYZER_TRF = AnalyzerEngine(nlp_engine=NLP_ENGINE_TRF, supported_languages=["en"])

每擦一个字符串就加载一次 transformer 模型。 这也解释了为什么脱敏要开 5 个 worker 并发跑。

另一个副作用:类体里直接写了「spaCy 模型没装就下载」的代码(presidio.py:34-39),也就是 import 这个模块就可能触发一次网络下载。这是本仓库反复出现的「import 有副作用」问题的又一例。


6. 顺带一提:录制怎么分享

legacy/openadapt/share.py 提供了一个很轻的分享通道:

  1. export_recording_to_folder(:28)把一份录制导成独立数据库文件。
  2. send_file(:103)调用 magic-wormhole(一种点对点加密传输工具,用一串人类可读的短码配对)发出去。
  3. receive_recording(:141)按短码接收。

没有服务器,没有账号。这和今天产品线的做法形成对照——README 描述的路径是「先本地 sanitize、人工审阅、按精确字节批准,才允许跨边界」,那套流程在 openadapt-flow 里,不在本克隆。


7. 边界与局限

  • 默认关闭。 config.SCRUB_ENABLED 默认 False(legacy/openadapt/config.py:183)。录制默认不脱敏
  • 仅英文。 supported_languages=["en"] 写死(presidio.py:59)。
  • 视频没实现。 ScrubbingProvider.scrub_mp4NotImplementedError(privacy/base.py:90),Presidio 声明的能力里也不含 MP4
  • 强擦是不可逆的。 force_scrub_children 一旦打开,那一片文本变成 ****,原意再也读不出来。这是明确的取舍:宁可信息丢失,不可泄漏。

8. 代码地图

主题文件路径符号名
提供方契约与能力标签legacy/openadapt/privacy/base.pyScrubbingProviderModality
递归打码与传染规则legacy/openadapt/privacy/base.pyTextScrubbingMixin.scrub_dict_scrub_text_itemscrub_text_all
提供方工厂legacy/openadapt/privacy/providers/__init__.pyScrubProvider.get_scrubber
Presidio 实现legacy/openadapt/privacy/providers/presidio.pyPresidioScrubbingProvider.scrub_text
脱敏编排与并发legacy/openadapt/scrub.pyscrubScrubbingProcstart_workers
复制与单项脱敏legacy/openadapt/db/crud.pycopy_recordingscrub_item
脱敏产物登记legacy/openadapt/models.pyScrubbedRecordingActionEvent.scrub
点对点分享legacy/openadapt/share.pyexport_recording_to_foldersend_recordingreceive_recording