deid_public.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. # -*- coding: utf-8 -*-
  2. """上云交付件脱敏 (2026-09-01)。
  3. ## 为什么单独一层
  4. 四张内容页是手写的, 一开始就按脱敏写, 所以干净。**快照不是** —— 它是从本体与 SCADA
  5. 产物生成的, 里面带着对象 id (`mechanism/rudong/...`)、源文件路径
  6. (`outputs/rudong/windscada/alarms.parquet`)、设备元数据 (`Siemens` / `SWT-4.0-130`)。
  7. 实测部署用的 index.html 里: rudong ×366 · Siemens ×194 · SWT ×89。
  8. **而交付页自己写着"site, owner and OEM removed"** —— 产物与声明不符, 这比没声明更糟。
  9. ## 两个坑
  10. 1. **`scripts/redact_check.py` 的词表里没有 `rudong`**, OEMS 里也没有 Siemens/SWT ——
  11. 本项目做得最多的这个场, 恰恰是那道闸看不见的。已同步补进去。
  12. 2. 脱敏是**全局一致重命名**: 对象 id 里的 farm token 换掉后, 引用它的地方必须同样换,
  13. 否则页面按旧 id 取数会 miss。所以只能在**最终字节**上整体做, 不能分片做。
  14. ## 铁律
  15. 替换后必须**回扫验证**: 还有任一可识别 token 残留就响亮失败, 不出文件。
  16. "过了脱敏"这句话只能由回扫结果说, 不能由"我替换过了"说。
  17. """
  18. import re
  19. # 显式映射表。左侧是要消掉的, 右侧是对外口径。
  20. TOKENS = [
  21. ('rudong', 'guanlan'), ('Rudong', 'Guanlan'), ('RUDONG', 'GUANLAN'),
  22. ('ruhai', 'guanlan'), ('Ruhai', 'Guanlan'), ('RUHAI', 'GUANLAN'), # 三维工作台源里的化名 (2026-09-07), 与 如海→观澜 对齐
  23. ('如东', '观澜'), ('如海', '观澜'),
  24. ('江苏', '[province]'), ('盐城', '[city]'), ('南通', '[city]'), ('如皋', '[county]'), ('启东', '[county]'), ('海门', '[county]'), # 2026-09-07: 回扫表早有 '省市' 项, 替换表却没有 → 含省市名的件必死; 对齐
  25. ('西门子', 'the OEM'), ('Siemens', 'OEM'), ('SIEMENS', 'OEM'),
  26. ('歌美飒', 'the OEM'), ('Gamesa', 'OEM'),
  27. # 2026-09-03 补齐: 以下 20 条此前**只在 windscada_cloud_pack.PARTY 里有**, 本表一条没有。
  28. # 后果分两种, 都很坏:
  29. # 业主集团 (中广核/华能/…) —— 回扫认识但替换表没有 ⇒ 出口闸永远 FAIL 且无法自动修;
  30. # 上海电气 / ABB / 内部系统名 —— **两边都没有** ⇒ 双盲, 闸报"干净"而实名照出 (实逮)。
  31. # 长词必须排在短词前 (顺序替换): ABB ACS880-87LC → ACS880 → ABB。
  32. ('中广核', '[owner]'), ('华能', '[owner]'), ('国家能源', '[owner]'), ('国电投', '[owner]'),
  33. ('大唐', '[owner]'), ('华电', '[owner]'), ('三峡', '[owner]'), ('中电投', '[owner]'),
  34. ('国电', '[owner]'),
  35. ('上海电气', 'OEM'),
  36. ('ABB ACS880-87LC', 'converter'), ('ACS880-87LC', 'converter'),
  37. ('ABB ACS880', 'converter'), ('ACS880', 'converter'), ('ABB', 'supplier'),
  38. ('m5_cms_tcm', 'CMS dataset'), ('windcms', 'CMS module'), ('findings.json', 'findings store'),
  39. ('analysis_kit', 'analysis library'), ('sop_check', 'SOP checker'),
  40. ]
  41. # ★厂商名还会藏在**文档标题**里 (实逮 `WTDL2_1.81.20.15_Envision_Release_Note.pdf`)。
  42. # 文档标题是一个独立的泄漏面 —— 它不走 id 归一, 也不在设备元数据里, 而 technical 页的
  43. # 流程声明恰好写着闸覆盖 "document titles"。声明与实现又一次不符。
  44. # 边界用**非字母**而不是 \b: 下划线是 \w, 所以 `_Envision_` 里根本没有词边界,
  45. # \b 一个也逮不到 —— 而 id/文件名恰恰全是下划线连接的。
  46. _VENDORS = ['Envision', '远景', 'Goldwind', '金风', 'Mingyang', '明阳', 'Sinovel', '华锐',
  47. 'Vestas', '维斯塔斯', 'Nordex', 'Senvion', 'Suzlon', 'Acciona']
  48. _B = lambda w: r'(?<![A-Za-z])' + re.escape(w) + r'(?![A-Za-z])'
  49. # ★短 token 的边界要更严: 'GE' 只有两个字母, 用 (?<![A-Za-z]) 会被 '3GE7' 这类
  50. # 字母数字串误命中, 故对 ≤3 字符的拉丁 token 改用 (?<![A-Za-z0-9])。
  51. _BS = lambda w: r'(?<![A-Za-z0-9])' + re.escape(w) + r'(?![A-Za-z0-9])'
  52. # ★2026-09-01 补: GE / General Electric。此前名单里没有它 —— 于是"31 GE codes"
  53. # 这条**已经死掉但仍被序列化进页面**的旧译文, 一路穿过脱敏闸进了交付件。
  54. # 教训: 译表本身会被 value_script 序列化进页面, 死 key 的英文照样外泄。
  55. _VENDORS += ['General Electric', '通用电气', 'Enercon', 'Siemens Gamesa', 'SGRE', 'GE']
  56. # 2026-09-03 selfcheck() 上线**第一次跑就逮到**: 替换表里有 Siemens / SIEMENS / Gamesa,
  57. # 而回扫只认复合词 'Siemens Gamesa' ⇒ 单独出现的 Siemens 换得掉但扫不出来。
  58. # 与当天早些时候上云闸漏掉的 Siemens 是同一个洞的两半 —— 一个闸漏替换, 另一个闸漏回扫。
  59. # 顺序在 'Siemens Gamesa' 之后, 长词先匹配不受影响。
  60. _VENDORS += ['Siemens', 'Gamesa']
  61. VENDOR_RE = [(re.compile((_BS if len(v) <= 3 and v.isascii() else _B)(v), re.I), 'the OEM')
  62. for v in _VENDORS]
  63. # 机型号: 精确到型号的字符串唯一可指向厂商与机组, 换成能力等价的泛称。
  64. # 容量 (4.0MW) 保留 —— 工程结论离开容量无法自洽 (P=½ρAv³), 且 4MW 级海上机组
  65. # 国内有几十个场, 单凭容量不构成识别。型号才是识别项。
  66. # ★必须覆盖 **id 归一形式**: ASCII id 迁移把标题压成小写下划线, 于是
  67. # `SWT-4.0-130` 在对象 id 里变成 `swt_4_0_130` —— 大小写敏感的模式一个也逮不到。
  68. # 实测漏在 `doc/4_0_4_0mw_swt_4_0_130_pdf_0bc3f1` 上。凡"人写的字符串"进过
  69. # id 归一, 脱敏与回扫都要按归一后的形态再写一遍。
  70. MODEL_RE = [
  71. (re.compile(r'swt[-_]?4[-_.]0[-_]130', re.I), 'offshore-4-0-platform'),
  72. (re.compile(r'swt[-_]?3[-_.]6[-_]120', re.I), 'offshore-3-6-platform'),
  73. (re.compile(r'swt[-_]?3[-_.]6', re.I), 'offshore-3-6-platform'),
  74. (re.compile(r'swt[-_]?4[-_.]0', re.I), 'offshore-4-0-platform'),
  75. # ★兜底原用 \b: 在 `siemens_swt40_rudong.yaml` / `swt130_4000` 里 `_s` 之间没有词边界 (下划线是 \w), 于是逃过替换,
  76. # 而回扫 LEAK_RE 机型号 用的是 (?<![A-Za-z]) 逮得到 → 单盲 (2026-09-07 20 MB 单文件实逮 2 处). 边界改与回扫同形.
  77. # 且兜底不能贪吃整段 slug: `tpl-swt-subsystem-1.html` 五个模板 id 全被吃成同一个 `tpl-offshore-platform` (重复 id, 模板串位).
  78. # 拆两步: 带型号数字的 → offshore-platform; 裸 swt 词 → offshore (保留其后的 -subsystem-1.html).
  79. (re.compile(r'(?<![A-Za-z])swt[-_]?\d[\d._-]*(?<![-_.])', re.I), 'offshore-platform'),
  80. (re.compile(r'(?<![A-Za-z])swt(?![A-Za-z])', re.I), 'offshore'),
  81. ]
  82. # ── 文档名与资料目录 ────────────────────────────────────────────────
  83. # 这是**独立于实名的第三个泄漏面**: 文档名不走 id 归一, 也不在设备元数据里, 但它把
  84. # 业主的整份技术资料目录结构交出去 (2026-09-03 公网实测: 实名已脱, 而
  85. # "4.0MW各部件资料/SB540-A06 液压盘式制动器用户手册.pdf" 原样外泄, 回扫还报干净)。
  86. # windscada_cloud_pack 早有 DOCNAME 规则, 本模块没有 —— 又一次"出口闸弱于打包闸"。
  87. # 逐项列举挡不住 (换一批文档又漏), 故按**扩展名整体替换**成类型化通用名。
  88. # ★这里的 scrub 作用在**单个字符串值**上 (recursion 到 str), 所以不带 JSON 引号,
  89. # 与 cloud_pack 那份按引号匹配的规则形态不同, 但落点一致。
  90. _DOCEXT = r'pdf|xlsx|xls|docx|doc|pptx|ppt|7z|zip|dwg|step|stp'
  91. # ★文档名普遍**带空格** ("SB540-A06 液压盘式制动器用户手册.pdf")。用 \s 排除会在空格处断掉,
  92. # 于是只换掉最后一段, 目录名与件号残留 —— 实测 "4.0MW各部件资料/SB540-A06 [OEM manual]",
  93. # 而回扫只认扩展名, 换完就报干净 ⇒ **残留静默**。
  94. # 改为: 允许空格与中英文, 但不跨标点/引号/换行 —— 既能整条收口, 又不会吃掉前面的句子。
  95. _DOCCH = r'[^"\'\n,,。;;::()()\[\]]'
  96. # 带路径的文档名 (目录名本身 = 结构信息) 整条收口. ★原写法 `_DOCCH*/_DOCCH*\.ext` 两侧无界, 在满是 `/` 且无标点的长行
  97. # (20 MB 单文件里的内联 JSON/JS) 上是多项式回溯, 2026-09-07 实逮 >20 min 不出来. 改为**按分隔符切段 + 只在 .ext 前 400 字符窗内匹配**:
  98. # 语义不变 (仍是"同一段内含 / 且以 .ext 结尾的整串"), 复杂度线性.
  99. _DOC0 = re.compile(r'%s{0,200}/%s{0,200}\.(?:%s)$' % (_DOCCH, _DOCCH, _DOCEXT), re.I)
  100. _SEG_DELIM = re.compile(r'["\'\n,,。;;::()()\[\]]')
  101. _EXT_RE = re.compile(r'\.(?:%s)(?![A-Za-z0-9_])' % _DOCEXT, re.I)
  102. def _doc_path_seg(seg):
  103. if '/' not in seg or not _EXT_RE.search(seg):
  104. return seg
  105. res, last = [], 0
  106. for e in _EXT_RE.finditer(seg):
  107. if e.start() < last:
  108. continue
  109. start = max(last, e.end() - 401)
  110. m = _DOC0.search(seg[start:e.end()])
  111. if m:
  112. res.append(seg[last:start + m.start()]); res.append('[technical document]'); last = e.end()
  113. res.append(seg[last:])
  114. return ''.join(res)
  115. def _doc_path_scrub(text):
  116. out, pos = [], 0
  117. for d in _SEG_DELIM.finditer(text):
  118. out.append(_doc_path_seg(text[pos:d.start()])); out.append(d.group(0)); pos = d.end()
  119. out.append(_doc_path_seg(text[pos:]))
  120. return ''.join(out)
  121. # ★扩展名后加词边界 (?![A-Za-z0-9_]): 2026-09-07 实逮 JS 里的 `document.documentElement` 被当成 "document.doc" 文档名替换成 `[technical document]umentElement`,
  122. # 云端页所有内嵌仿真的内联脚本语法错 → 图区空白 (用户报 "好几个仿真打不开"). 构建器另加内联脚本 node 语法闸兜底.
  123. _JS_IDENT = re.compile(r'\s*[A-Za-z_$][A-Za-z0-9_$]*\??') # 允许可选链 s?.step
  124. _JS_OPS = set('=([.|&),;?:+-*/<>!%^~{}')
  125. def _doc_rep(rep):
  126. """文档名替换器: 名部是纯 ASCII 标识符且扩展名后紧跟 JS 运算符 (= ( [ .) 的, 是代码属性访问 (e.step=30 / x.zip(a)), 不是文档名, 原样保留;
  127. 其余 (含中文/空格/数字/连字符的名部, 或扩展名后是括注/标点/结尾) 照常替换. 2026-09-07 实逮: 内联脚本被改坏 vs 中文行文 ".pdf(716页" 需照常脱敏, 两头都得对."""
  128. def f(m):
  129. whole = m.group(0); name = whole[:whole.rfind('.')]; nxt = m.string[m.end():m.end() + 1]
  130. if _JS_IDENT.fullmatch(name) and nxt in _JS_OPS: return whole
  131. return rep
  132. return f
  133. DOC_RE = [
  134. (re.compile(r'%s{0,90}?(?:手册|指南|说明书|manual)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[OEM manual]'),
  135. (re.compile(r'%s{0,90}?(?:图纸|原理图|接线图|示意图|drawing|schematic)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[OEM drawing]'),
  136. (re.compile(r'%s{0,90}?(?:接线表|参数表|清单|台账|检查表|指导书)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[reference table]'),
  137. (re.compile(r'%s{2,120}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCEXT), re.I), '[technical document]'), # 泛规则: 名部 ≥2 字 + 扩展名后非 JS 运算符 (2026-09-07 实逮 `e.step=30` 被吃)
  138. ]
  139. # 回扫用: 出现任一即判未脱敏干净。与上面的替换表**分开写** ——
  140. # 替换表漏了什么, 回扫要能独立发现, 两者共用一份就同时瞎。
  141. LEAK_RE = [
  142. # ★一律大小写不敏感。上一版这里是敏感的, 于是 id 归一后的小写形态 (swt_4_0_130)
  143. # 一路穿过回扫 —— **回扫自己也有和被查对象同一种盲区**, 这是最难发现的一类。
  144. ('场名拼音', re.compile(r'rudong|ruhai', re.I)),
  145. ('场名中文', re.compile(r'如东|如海')),
  146. # 边界一律用**非字母**: `\b` 在下划线两侧不成立, 而文件名/id 全是下划线连接的。
  147. # ★2026-09-01 改为从 _VENDORS **派生**, 不再手抄第二份名单。
  148. # 根因: scrub() 走 VENDOR_RE, audit() 走这里, 两份名单靠人记着同步 —— 结果漂了:
  149. # 补 GE 只补进 _VENDORS, audit 这边不知道, 于是「31 GE codes」被 scrub 换掉了
  150. # 但**回扫报干净**。两套判据查同一件事就必须同源, 否则闸自己有盲区。
  151. ('OEM 名', re.compile('|'.join(
  152. (_BS if len(v) <= 3 and v.isascii() else _B)(v) for v in _VENDORS) + '|西门子|歌美飒',
  153. re.I)),
  154. ('机型号', re.compile(r'(?<![A-Za-z])swt[-_\w.]*', re.I)),
  155. ('省市', re.compile(r'江苏|盐城|南通|如皋|启东|海门')),
  156. ('业主集团', re.compile(r'华能|国电投|国电|大唐|华电|三峡|中广核|国家能源|中电投')),
  157. ('坐标', re.compile(r'\b\d{2,3}\.\d{4,}\s*[°ºNEWS]')),
  158. # 与上面的 OEM 名分开列: 那条从 _VENDORS 派生 (国际厂商), 这里是中文整机厂与部件供应商。
  159. # 「上海电气」此前两表皆无 ⇒ 双盲 (2026-09-03 出口闸实逮)。
  160. ('供应商实名', re.compile(r'上海电气|(?<![A-Za-z0-9])ABB(?![A-Za-z0-9])|ACS880')),
  161. ('内部系统名', re.compile(r'm5_cms_tcm|windcms|findings\.json|analysis_kit|sop_check')),
  162. # 文档名/资料目录: 任何残留的技术文档扩展名都算泄露 (它必然带着原始文件名)
  163. ('文档名', re.compile(r'(?![A-Za-z_$][A-Za-z0-9_$]*\??\.(?:%s)[=(\[.|&),;?:+\-*/<>!%%^~{}])[\w\u4e00-\u9fff\-. ]+\.(?:%s)(?![A-Za-z])' % (_DOCEXT, _DOCEXT), re.I)), # 回扫与替换同口径: JS 属性访问 (e.step= / x.zip() 不算文档名
  164. ]
  165. def selfcheck():
  166. """替换表 x 回扫表 的**交叉自检** —— 防两表出现共同空洞。
  167. 两表分开写是对的 (互为独立发现), 但代价是可能一起漏同一个词, 而那种漏**静默**:
  168. 闸跑完报干净, 实名照样出去。2026-09-03 实逮「上海电气」正是此类。
  169. 对替换表里每个原词要求两件事:
  170. 1 未脱敏的原文放进去, 回扫必须逮得到 (回扫认识它)
  171. 2 脱敏之后再回扫, 必须干净 (替换换得掉)
  172. 返回问题列表; 空 = 两表覆盖面自洽。
  173. """
  174. bad = []
  175. for a, _ in TOKENS:
  176. if not audit(a):
  177. bad.append('回扫不认识替换表里的 %r — 若它同时从替换表漏掉就是双盲' % a)
  178. elif audit(scrub(a)):
  179. bad.append('替换 %r 之后回扫仍报 %s' % (a, [n for n, _, _ in audit(scrub(a))]))
  180. for v in _VENDORS:
  181. if not audit(v):
  182. bad.append('回扫不认识 _VENDORS 里的 %r' % v)
  183. return bad
  184. def scrub(text):
  185. """整段脱敏。**只做全局一致替换** —— 对象 id 与引用它的地方必须换成同一个词。"""
  186. for a, b in TOKENS:
  187. text = text.replace(a, b)
  188. for rx, rep in MODEL_RE:
  189. text = rx.sub(rep, text)
  190. for rx, rep in VENDOR_RE:
  191. text = rx.sub(rep, text)
  192. text = _doc_path_scrub(text) # 文档名放最后: 前面的实名替换先落, 这里再整条收口 (带路径的先, 线性实现)
  193. for rx, rep in DOC_RE:
  194. text = rx.sub(_doc_rep(rep), text)
  195. return text
  196. def audit(text):
  197. """回扫。→ [(类别, 命中次数, 样例)]; 空列表 = 干净。"""
  198. out = []
  199. for name, rx in LEAK_RE:
  200. m = rx.findall(text)
  201. if m:
  202. out.append((name, len(m), sorted(set(m))[:4]))
  203. return out
  204. def scrub_or_die(text, label='交付件'):
  205. """脱敏 + 回扫; 有残留则 raise。
  206. ★不允许"替换过了就算过" —— 替换表总会漏, 只有回扫结果算数。
  207. """
  208. out = scrub(text)
  209. leaks = audit(out)
  210. if leaks:
  211. detail = '; '.join('%s×%d %s' % (n, c, s) for n, c, s in leaks)
  212. raise RuntimeError('%s 脱敏后仍有可识别信息, 拒绝出件: %s' % (label, detail))
  213. return out