# -*- coding: utf-8 -*- """上云交付件脱敏 (2026-09-01)。 ## 为什么单独一层 四张内容页是手写的, 一开始就按脱敏写, 所以干净。**快照不是** —— 它是从本体与 SCADA 产物生成的, 里面带着对象 id (`mechanism/rudong/...`)、源文件路径 (`outputs/rudong/windscada/alarms.parquet`)、设备元数据 (`Siemens` / `SWT-4.0-130`)。 实测部署用的 index.html 里: rudong ×366 · Siemens ×194 · SWT ×89。 **而交付页自己写着"site, owner and OEM removed"** —— 产物与声明不符, 这比没声明更糟。 ## 两个坑 1. **`scripts/redact_check.py` 的词表里没有 `rudong`**, OEMS 里也没有 Siemens/SWT —— 本项目做得最多的这个场, 恰恰是那道闸看不见的。已同步补进去。 2. 脱敏是**全局一致重命名**: 对象 id 里的 farm token 换掉后, 引用它的地方必须同样换, 否则页面按旧 id 取数会 miss。所以只能在**最终字节**上整体做, 不能分片做。 ## 铁律 替换后必须**回扫验证**: 还有任一可识别 token 残留就响亮失败, 不出文件。 "过了脱敏"这句话只能由回扫结果说, 不能由"我替换过了"说。 """ import re # 显式映射表。左侧是要消掉的, 右侧是对外口径。 TOKENS = [ ('rudong', 'guanlan'), ('Rudong', 'Guanlan'), ('RUDONG', 'GUANLAN'), ('ruhai', 'guanlan'), ('Ruhai', 'Guanlan'), ('RUHAI', 'GUANLAN'), # 三维工作台源里的化名 (2026-09-07), 与 如海→观澜 对齐 ('如东', '观澜'), ('如海', '观澜'), ('江苏', '[province]'), ('盐城', '[city]'), ('南通', '[city]'), ('如皋', '[county]'), ('启东', '[county]'), ('海门', '[county]'), # 2026-09-07: 回扫表早有 '省市' 项, 替换表却没有 → 含省市名的件必死; 对齐 ('西门子', 'the OEM'), ('Siemens', 'OEM'), ('SIEMENS', 'OEM'), ('歌美飒', 'the OEM'), ('Gamesa', 'OEM'), # 2026-09-03 补齐: 以下 20 条此前**只在 windscada_cloud_pack.PARTY 里有**, 本表一条没有。 # 后果分两种, 都很坏: # 业主集团 (中广核/华能/…) —— 回扫认识但替换表没有 ⇒ 出口闸永远 FAIL 且无法自动修; # 上海电气 / ABB / 内部系统名 —— **两边都没有** ⇒ 双盲, 闸报"干净"而实名照出 (实逮)。 # 长词必须排在短词前 (顺序替换): ABB ACS880-87LC → ACS880 → ABB。 ('中广核', '[owner]'), ('华能', '[owner]'), ('国家能源', '[owner]'), ('国电投', '[owner]'), ('大唐', '[owner]'), ('华电', '[owner]'), ('三峡', '[owner]'), ('中电投', '[owner]'), ('国电', '[owner]'), ('上海电气', 'OEM'), ('ABB ACS880-87LC', 'converter'), ('ACS880-87LC', 'converter'), ('ABB ACS880', 'converter'), ('ACS880', 'converter'), ('ABB', 'supplier'), ('m5_cms_tcm', 'CMS dataset'), ('windcms', 'CMS module'), ('findings.json', 'findings store'), ('analysis_kit', 'analysis library'), ('sop_check', 'SOP checker'), ] # ★厂商名还会藏在**文档标题**里 (实逮 `WTDL2_1.81.20.15_Envision_Release_Note.pdf`)。 # 文档标题是一个独立的泄漏面 —— 它不走 id 归一, 也不在设备元数据里, 而 technical 页的 # 流程声明恰好写着闸覆盖 "document titles"。声明与实现又一次不符。 # 边界用**非字母**而不是 \b: 下划线是 \w, 所以 `_Envision_` 里根本没有词边界, # \b 一个也逮不到 —— 而 id/文件名恰恰全是下划线连接的。 _VENDORS = ['Envision', '远景', 'Goldwind', '金风', 'Mingyang', '明阳', 'Sinovel', '华锐', 'Vestas', '维斯塔斯', 'Nordex', 'Senvion', 'Suzlon', 'Acciona'] _B = lambda w: r'(?20 min 不出来. 改为**按分隔符切段 + 只在 .ext 前 400 字符窗内匹配**: # 语义不变 (仍是"同一段内含 / 且以 .ext 结尾的整串"), 复杂度线性. _DOC0 = re.compile(r'%s{0,200}/%s{0,200}\.(?:%s)$' % (_DOCCH, _DOCCH, _DOCEXT), re.I) _SEG_DELIM = re.compile(r'["\'\n,,。;;::()()\[\]]') _EXT_RE = re.compile(r'\.(?:%s)(?![A-Za-z0-9_])' % _DOCEXT, re.I) def _doc_path_seg(seg): if '/' not in seg or not _EXT_RE.search(seg): return seg res, last = [], 0 for e in _EXT_RE.finditer(seg): if e.start() < last: continue start = max(last, e.end() - 401) m = _DOC0.search(seg[start:e.end()]) if m: res.append(seg[last:start + m.start()]); res.append('[technical document]'); last = e.end() res.append(seg[last:]) return ''.join(res) def _doc_path_scrub(text): out, pos = [], 0 for d in _SEG_DELIM.finditer(text): out.append(_doc_path_seg(text[pos:d.start()])); out.append(d.group(0)); pos = d.end() out.append(_doc_path_seg(text[pos:])) return ''.join(out) # ★扩展名后加词边界 (?![A-Za-z0-9_]): 2026-09-07 实逮 JS 里的 `document.documentElement` 被当成 "document.doc" 文档名替换成 `[technical document]umentElement`, # 云端页所有内嵌仿真的内联脚本语法错 → 图区空白 (用户报 "好几个仿真打不开"). 构建器另加内联脚本 node 语法闸兜底. _JS_IDENT = re.compile(r'\s*[A-Za-z_$][A-Za-z0-9_$]*\??') # 允许可选链 s?.step _JS_OPS = set('=([.|&),;?:+-*/<>!%^~{}') def _doc_rep(rep): """文档名替换器: 名部是纯 ASCII 标识符且扩展名后紧跟 JS 运算符 (= ( [ .) 的, 是代码属性访问 (e.step=30 / x.zip(a)), 不是文档名, 原样保留; 其余 (含中文/空格/数字/连字符的名部, 或扩展名后是括注/标点/结尾) 照常替换. 2026-09-07 实逮: 内联脚本被改坏 vs 中文行文 ".pdf(716页" 需照常脱敏, 两头都得对.""" def f(m): whole = m.group(0); name = whole[:whole.rfind('.')]; nxt = m.string[m.end():m.end() + 1] if _JS_IDENT.fullmatch(name) and nxt in _JS_OPS: return whole return rep return f DOC_RE = [ (re.compile(r'%s{0,90}?(?:手册|指南|说明书|manual)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[OEM manual]'), (re.compile(r'%s{0,90}?(?:图纸|原理图|接线图|示意图|drawing|schematic)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[OEM drawing]'), (re.compile(r'%s{0,90}?(?:接线表|参数表|清单|台账|检查表|指导书)%s{0,40}?\.(?:%s)(?![A-Za-z0-9_])' % (_DOCCH, _DOCCH, _DOCEXT), re.I), '[reference table]'), (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` 被吃) ] # 回扫用: 出现任一即判未脱敏干净。与上面的替换表**分开写** —— # 替换表漏了什么, 回扫要能独立发现, 两者共用一份就同时瞎。 LEAK_RE = [ # ★一律大小写不敏感。上一版这里是敏感的, 于是 id 归一后的小写形态 (swt_4_0_130) # 一路穿过回扫 —— **回扫自己也有和被查对象同一种盲区**, 这是最难发现的一类。 ('场名拼音', re.compile(r'rudong|ruhai', re.I)), ('场名中文', re.compile(r'如东|如海')), # 边界一律用**非字母**: `\b` 在下划线两侧不成立, 而文件名/id 全是下划线连接的。 # ★2026-09-01 改为从 _VENDORS **派生**, 不再手抄第二份名单。 # 根因: scrub() 走 VENDOR_RE, audit() 走这里, 两份名单靠人记着同步 —— 结果漂了: # 补 GE 只补进 _VENDORS, audit 这边不知道, 于是「31 GE codes」被 scrub 换掉了 # 但**回扫报干净**。两套判据查同一件事就必须同源, 否则闸自己有盲区。 ('OEM 名', re.compile('|'.join( (_BS if len(v) <= 3 and v.isascii() else _B)(v) for v in _VENDORS) + '|西门子|歌美飒', re.I)), ('机型号', re.compile(r'(?!%%^~{}])[\w\u4e00-\u9fff\-. ]+\.(?:%s)(?![A-Za-z])' % (_DOCEXT, _DOCEXT), re.I)), # 回扫与替换同口径: JS 属性访问 (e.step= / x.zip() 不算文档名 ] def selfcheck(): """替换表 x 回扫表 的**交叉自检** —— 防两表出现共同空洞。 两表分开写是对的 (互为独立发现), 但代价是可能一起漏同一个词, 而那种漏**静默**: 闸跑完报干净, 实名照样出去。2026-09-03 实逮「上海电气」正是此类。 对替换表里每个原词要求两件事: 1 未脱敏的原文放进去, 回扫必须逮得到 (回扫认识它) 2 脱敏之后再回扫, 必须干净 (替换换得掉) 返回问题列表; 空 = 两表覆盖面自洽。 """ bad = [] for a, _ in TOKENS: if not audit(a): bad.append('回扫不认识替换表里的 %r — 若它同时从替换表漏掉就是双盲' % a) elif audit(scrub(a)): bad.append('替换 %r 之后回扫仍报 %s' % (a, [n for n, _, _ in audit(scrub(a))])) for v in _VENDORS: if not audit(v): bad.append('回扫不认识 _VENDORS 里的 %r' % v) return bad def scrub(text): """整段脱敏。**只做全局一致替换** —— 对象 id 与引用它的地方必须换成同一个词。""" for a, b in TOKENS: text = text.replace(a, b) for rx, rep in MODEL_RE: text = rx.sub(rep, text) for rx, rep in VENDOR_RE: text = rx.sub(rep, text) text = _doc_path_scrub(text) # 文档名放最后: 前面的实名替换先落, 这里再整条收口 (带路径的先, 线性实现) for rx, rep in DOC_RE: text = rx.sub(_doc_rep(rep), text) return text def audit(text): """回扫。→ [(类别, 命中次数, 样例)]; 空列表 = 干净。""" out = [] for name, rx in LEAK_RE: m = rx.findall(text) if m: out.append((name, len(m), sorted(set(m))[:4])) return out def scrub_or_die(text, label='交付件'): """脱敏 + 回扫; 有残留则 raise。 ★不允许"替换过了就算过" —— 替换表总会漏, 只有回扫结果算数。 """ out = scrub(text) leaks = audit(out) if leaks: detail = '; '.join('%s×%d %s' % (n, c, s) for n, c, s in leaks) raise RuntimeError('%s 脱敏后仍有可识别信息, 拒绝出件: %s' % (label, detail)) return out