#!/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'(?=<]*\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'(?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())