| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """云版打包 — 源码级脱敏 + 泄露核查闸。
- 为什么单独一条路径, 不改 /api/snapshot:
- 内部快照要保留真实路径与场名 (工程师要照着去取数)。云版是另一个用途,
- 两者混在一个开关里迟早互相污染。故: 内部快照照旧生成, 云版从它派生。
- 为什么是源码级不是显示层:
- __SNAP__ 把所有 API 响应完整烤进 HTML, 显示层只译/只抽象**渲染出来的**那部分。
- 上云后客户 View Source 能读到全部 JSON —— 显示层脱敏在这个威胁模型下无效。
- 退役判据: 若快照生成端改为按 profile 只烤客户可见字段 (源头就没有敏感值),
- 本脚本降级为核查闸 (只跑 audit 不做替换)。
- """
- import argparse, json, pathlib, re, sys
- # ── 文档名整体替换 (必须排在最前) ─────────────────────────────────
- # 2026-08-30 实体扫描逮到: 本体 Doc 对象带进来 73 个真实文档名, 里面同时含
- # 业主名 中广核如东10400分析及建议.pdf / 华能如东中段塔架支撑示意图.pdf
- # 供应商 上海电气 故障处理手册.xlsx / ABB ACS880-87LC 十余本手册
- # 图纸号 V304002 / D1194234-111124 / 物料号 1SDH000460R0002CN
- # 部件型号 7-Y013.60.3035K2 偏航回转支承
- # 等于把业主的技术资料目录整份交出去。逐项列举挡不住(下次换一批文档又漏),
- # 故按**扩展名整体替换**: 不管里面写了什么, 一律换成类型化通用名。
- # ★用户早先点名删过的 SB540-A06 液压盘式制动器用户手册.pdf 这次又回来了 ——
- # 扩展预烤把 Doc 对象全烤进产物。证明闸必须每次跑, "上次删过了"不作数。
- DOCNAME = [
- # 路径形式的文档引用: 前面的目录段可能已被别的规则部分替换 (site data / OEM ...),
- # 剩下的仍暴露资料目录结构。整条换掉, 不做长度限制。
- (r'"[^"]*/[^"]*\.(pdf|xlsx|xls|docx|doc|pptx|7z|zip)"', '"[technical document]"'),
- (r'"([^"]{2,90}?(?:手册|指南|说明书)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"', '"[OEM manual]"'),
- (r'"([^"]{2,90}?(?:图纸|原理图|接线图|示意图|drawing|schematic)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"',
- '"[OEM drawing]"'),
- (r'"([^"]{2,90}?(?:接线表|参数表|清单|台账)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"', '"[reference table]"'),
- (r'"([^"]{2,}?)\.(pdf|xlsx|xls|docx|doc|pptx|7z|zip)"', '"[technical document]"'),
- ]
- # ── 业主与供应商实名 ───────────────────────────────────────────────
- # 文档名在正文里常被截断成不带扩展名的形式 (如 "2-2 上海风电变压器操作维护手册-中文版"
- # 去掉 .pdf 后仍是文档名), DOCNAME 那几条靠扩展名匹配, 抓不到这种。补按**特征词**收尾。
- DOCFRAG = [
- (r'[0-9\-\s]*上海风电变压器操作维护[^,,。;;\s"]*', '[OEM manual]'),
- (r'[^,,。;;\s"]{0,12}项目产品[^,,。;;\s"]{0,12}', '[project document]'),
- (r'新增设备电气接口图[^,,。;;\s"]*', '[interface drawing]'),
- (r'[0-9.]*\s*原理图_[0-9\-]+[^,,。;;\s"]*', '[schematic]'),
- (r'-\s*中文版', ''),
- ]
- PARTY = [
- # 业主 (集团名出现在文档名与分析件名里)
- ('中广核', '[owner]'), ('华能', '[owner]'), ('国家能源', '[owner]'),
- ('国电投', '[owner]'), ('大唐', '[owner]'), ('华电', '[owner]'), ('三峡', '[owner]'),
- # 整机与部件供应商
- ('上海电气', 'OEM'), ('Envision', 'supplier'), ('远景', 'supplier'),
- ('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'),
- ]
- # ── 无争议脱敏: 本机路径 / 场名 / OEM ───────────────────────────────
- # 顺序敏感: 长串先替, 否则 '如东' 先被替掉会让后面的长路径匹配不上
- SUBS = [
- # 本机绝对路径 (含用户名) → 抽象位置
- (r'/Users/[^/"]+/rudong/如东风场数据', '[site data]'),
- (r'/Users/[^/"]+/rudong', '[site data]'),
- (r'/Users/[^/"]+', '[local path]'),
- (r'/Volumes/[A-Za-z0-9_]+', '[data volume]'),
- # 场名
- ('如东海上', 'the offshore site'),
- ('如东', 'the site'),
- ('rudong', 'site'),
- ('RUDONG', 'SITE'),
- ('Rudong', 'Site'),
- # OEM / 机型
- ('西门子4.0技术资料', '[OEM technical library]'),
- ('西门子4.0', 'OEM 4.0 MW'),
- ('西门子', 'OEM'),
- # 英文形态 (2026-09-03 v2 快照实逮): 厂商英文码表里的报警名自带厂名 —— "Remote stop-Siemens" /
- # "DG:Local power limit, Siemens"。替换表只有中文形, 回扫规则却认英文 ⇒ 闸 FAIL 7 处。与中文同一落点。
- ('Siemens Gamesa', 'OEM'), ('Siemens', 'OEM'), ('SIEMENS', 'OEM'),
- ('SWT-4.0-130', '4.0 MW class'),
- ('SWT130-4000', '4.0 MW class'),
- ]
- # ── 经济数字: 默认脱, --keep-money 放开 ────────────────────────────
- # 这些是业主真实损失与上网电价。技术脱敏解决不了授权问题, 故默认最保守。
- MONEY = [
- # 电价基准 (中国上网电价区间) → 参考基准
- (r'电价区间\s*[\d.]+\s*~\s*[\d.]+\s*元/kWh', '按参考电价基准'),
- (r'[\d.]+\s*~\s*[\d.]+\s*元/kWh', '参考电价基准'),
- (r'[\d.]+\s*元/kWh', '参考电价基准'),
- ]
- # ── 机组编号代号化 (2026-08-30 用户令: 用代号一次替换最简单) ────────
- # 我先前主张保留原编号 (8323 处, 是全系统主键), 用户裁决用代号 —— 执行。
- # 两个实现约束:
- # 1) 代号必须**纯字母数字**: 前端有 8 处 t.replace("WTG","") 把 WTG16 剥成 16 再显示成
- # "16#"。代号不含 WTG 时这些 replace 自然失效, 显示成 "A7#", 与原形态同构不破版。
- # 2) 映射必须**确定性**: 同一台在十个页面、本体 id、下钻链接里必须是同一个代号,
- # 两次出包也必须一致 (否则新旧链接对不上)。故用 md5 定序而非随机。
- # 代号刻意不连续 (A7 / K23 / C4), 数不出总台数 —— 这正是"隐藏台数"要的效果。
- def _unit_map(s):
- import hashlib
- ids = sorted(set(re.findall(r'WTG(\d{2})', s)))
- m, used = {}, set()
- for i in ids:
- h = hashlib.md5(('guanlan-unit-' + i).encode()).hexdigest()
- a, n = chr(65 + int(h[:2], 16) % 26), int(h[2:6], 16) % 90 + 10
- # 生日悖论: 26×90 的空间里 38 台仍会撞 (首版实测 38 台只出 36 个代号)。
- # 撞了就沿数字轴确定性平移 —— 仍然可复现, 且绝不把两台并成一个代号。
- while a + str(n) in used:
- n = (n - 9) % 90 + 10
- used.add(a + str(n))
- m[i] = a + str(n)
- if len(set(m.values())) != len(m):
- raise ValueError(f'代号冲突: {len(m)} 台只生成 {len(set(m.values()))} 个代号')
- return m
- def unitize(s):
- m = _unit_map(s)
- for i, c in m.items():
- s = s.replace('WTG' + i, c) # 本体 id / t 字段 / href 一并覆盖
- s = re.sub(r'(?<!\d)' + i + r'#', c + '#', s) # 数据里硬编码的 "16#" 显示形式
- return s
- # ── 机组台数 (2026-08-30 用户令: 机组台数和地区都隐藏) ──────────────
- # 只脱**显式台数**, 不动机组编号 WTGnn:
- # 编号是全系统的主键 (8323 处, 图表标签/排序/下钻链接/本体对象 id 都用它),
- # 映射成代号要保证跨页一致 + 不破坏数值排序, 风险远大于收益;
- # 而客户就算逐个数编号也得先把十个页面翻遍。显式的 "38 台" 才是一眼可见的。
- # 地区已由场名规则覆盖 (如东 → the site); "海上/offshore" 保留 —— 那是机型工况特征,
- # 不指向任何具体地区, 脱了反而让技术描述失真。
- FLEETSIZE = [
- (r'(?<![\d.])38\s*台', 'the fleet'),
- (r'全场\s*38\b', '全场'),
- (r'/\s*38(?![\d.])', '/ N'),
- (r'(?<![\d.])38(?=\s*(?:台|units|turbines))', 'N'),
- ]
- # ── 判据门槛: 客户版不给数值阈 (memory client-deliverable-hide-core-parameters) ──
- # 服务端 _redact 漏了 fig.note 这一路 —— 浮层里 "报警门 ±0.6°" 是直接印出来的判据门槛。
- THRESH = [
- (r'(报警门|alarm gate)\s*±?\s*[\d.]+\s*[°℃%]?', r'\1 ▪'),
- (r'(门槛|阈值|判据门)\s*[≥≤>=<]*\s*[\d.]+\s*[°℃%]?', r'\1 ▪'),
- ]
- def scrub(s, keep_money=False):
- for pat, rep in DOCNAME: # 文档名先整体换掉, 免得后续逐词替换在里面留半截
- s = re.sub(pat, rep, s)
- for pat, rep in DOCFRAG:
- s = re.sub(pat, rep, s)
- for k, v in PARTY:
- s = s.replace(k, v)
- for pat, rep in SUBS:
- s = re.sub(pat, rep, s) if pat.startswith(('/', '[', '(')) or '\\' in pat or '[' in pat else s.replace(pat, rep)
- if not keep_money:
- for pat, rep in MONEY:
- s = re.sub(pat, rep, s)
- for pat, rep in THRESH:
- s = re.sub(pat, rep, s)
- for pat, rep in FLEETSIZE:
- s = re.sub(pat, rep, s)
- s = unitize(s)
- return s
- # ── 泄露核查闸: 必须零命中, 否则 exit 2 ─────────────────────────────
- GATES = {
- '本机路径': r'/Users/[A-Za-z0-9_.-]+/',
- '数据盘路径': r'/Volumes/[A-Za-z0-9_]+/',
- '场名(中)': '如东',
- '场名(英)': r'[Rr]udong|RUDONG',
- 'OEM名': '西门子',
- '机型': r'SWT[-\d]',
- '判据门槛': r'(?:报警门|alarm gate|门槛|阈值)\s*[±≥≤>=<]*\s*[\d.]+',
- '业主实名': r'中广核|华能|国家能源|国电投|大唐|华电|三峡',
- '供应商实名': r'上海电气|Envision|远景|\bABB\b|ACS880|Siemens|Gamesa|SKF|FAG|Flender|Winergy',
- # ★上限 120 曾让长路径逃过检查 (2026-08-31 实逮: 部分替换后路径变长, 反而不匹配)。
- # 判据的长度上限本身就是盲区 —— 越长的路径泄露越多, 却越不会被抓到。改为无上限。
- '文档名': r'"[^"]{2,}\.(?:pdf|xlsx|xls|docx|doc|pptx|7z|zip)"',
- '内部系统名': r'm5_cms_tcm|windcms|findings\.json|analysis_kit|sop_check',
- '显式台数': r'(?<![\d.])38\s*(?:台|units|turbines)|/\s*38(?![\d.])',
- '原始机组号': r'WTG\d{2}',
- }
- GATES_MONEY = {
- '上网电价': r'元/kWh',
- }
- def audit(s, keep_money=False):
- gates = dict(GATES)
- if not keep_money:
- gates.update(GATES_MONEY)
- hits = {}
- for name, pat in gates.items():
- m = re.findall(pat, s)
- if m:
- hits[name] = len(m)
- return hits
- def main():
- ap = argparse.ArgumentParser()
- ap.add_argument('src', help='内部快照 HTML')
- ap.add_argument('-o', '--out', required=True, help='云版输出路径')
- ap.add_argument('--keep-money', action='store_true',
- help='保留经济数字与电价 (需业主授权; 默认脱敏)')
- a = ap.parse_args()
- src = pathlib.Path(a.src)
- raw = src.read_text(encoding='utf-8')
- before = audit(raw, a.keep_money)
- out = scrub(raw, a.keep_money)
- after = audit(out, a.keep_money)
- dst = pathlib.Path(a.out)
- dst.parent.mkdir(parents=True, exist_ok=True)
- dst.write_text(out, encoding='utf-8')
- print('=' * 66)
- print(f'云版打包 — {src.name} → {dst.name}')
- print(f' 经济数字: {"保留 (须业主授权)" if a.keep_money else "已脱敏"}')
- print('=' * 66)
- print(f'{"项":14} {"脱敏前":>8} {"脱敏后":>8}')
- for k in sorted(set(before) | set(after)):
- print(f'{k:14} {before.get(k, 0):>8} {after.get(k, 0):>8}')
- if not before:
- print('(脱敏前即零命中)')
- print('-' * 66)
- print(f'输出: {dst} ({dst.stat().st_size/1024:.0f} KB)')
- if after:
- print(f'\n✗ 闸 FAIL — 残留 {sum(after.values())} 处: {after}')
- return 2
- n_gate = len(GATES) + (0 if a.keep_money else len(GATES_MONEY))
- print(f'\n✓ 闸 PASS — {n_gate} 项零命中, 可上云')
- return 0
- if __name__ == '__main__':
- sys.exit(main())
|