feat(audit): 快照嵌套分层渲染 + 凭据字段从接口剥离(安全修复)
起因:借库管理等单据的详情页仍显示原始 JSON 代码块。
根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组
(items 明细行),而旧实现只认平铺的 created/deleted_snapshot,
遇到非平铺结构就退化成原始 JSON。
1. 快照键收敛(后端)
created / deleted_snapshot / payload 三种键与展示标题收进
audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews,
前端不再硬编键名 —— 将来加第四种只改一处。
audit_export_service 与 daily_report_service 改为引用同一份。
changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要
此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、
备注、借用人、签名、预计归还时间」)。
2. 分层渲染(前端)
· 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤)
· 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键
并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel
· 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格
· details / payload 本身是数组或标量的情况也一并处理
· 只有确实无可识别结构时才回落到原始 JSON
实测全库 58728 条:**0 条**会退化到原始 JSON 兜底
(payload 1495 条全部拆出结构)。
3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离
实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条,
形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。
· sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口
· changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉
某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数)
· 凭据判定用**关键词**(password/passwd/secret/token/private_key)
而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是
reset_token。新表加凭据字段也自动被挡住。
★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里
密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉,
前端隐藏不是防线。**
验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」
「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」
「sanitize_details 递归进嵌套数组且不原地改原对象」。
6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。
⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** ——
改数据不可逆,需单独立项决定。
This commit is contained in:
@ -282,6 +282,57 @@ FIELD_LABELS = {
|
||||
'image_url': '图片地址',
|
||||
'user_id': '用户ID',
|
||||
'type': '类型',
|
||||
|
||||
# --- 快照里的数组字段 ---
|
||||
# ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染),
|
||||
# 漏一个就是在表头上裸露英文。
|
||||
'items': '物料明细',
|
||||
'rules': '规则',
|
||||
'children': '子项',
|
||||
'arrival_photo': '到货照片',
|
||||
'generalImage': '通用图片',
|
||||
'generalManual': '通用手册',
|
||||
'permissions': '权限',
|
||||
'signature_path': '签名',
|
||||
|
||||
# --- payload 型快照的字段 ---
|
||||
# ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。
|
||||
# 它的命名与 ORM 快照**不一致**,同一概念有两种写法:
|
||||
# qty_stock / stock_quantity —— 都是"库存数量"
|
||||
# commonName / common_name —— 都是"通用名"
|
||||
# companyName / company_name —— 都是"所属公司"
|
||||
# 这是历史遗留,改数据不可逆,故两种写法都登记。
|
||||
'qty_stock': '库存数量',
|
||||
'qty_available': '可用数量',
|
||||
'qty_inbound': '入库数量',
|
||||
'inventoryCount': '库存数量',
|
||||
'availableCount': '可用数量',
|
||||
'pending_quantity': '待处理数量',
|
||||
'inbound_date': '入库日期',
|
||||
'cn_name': '中文名',
|
||||
'commonName': '通用名',
|
||||
'companyName': '所属公司',
|
||||
'spec': '规格型号',
|
||||
'price': '价格',
|
||||
'purchaser': '采购员',
|
||||
'purchaser_email': '采购邮箱',
|
||||
'print_copies': '打印份数',
|
||||
'global_print_id_str': '全局打印ID(文本)',
|
||||
'source_link': '来源链接',
|
||||
'unit_total_cost': '单位总成本',
|
||||
'current_location': '当前位置',
|
||||
'isEnabled': '是否启用',
|
||||
'isInspectionRequired': '是否需质检',
|
||||
'visibilityLevel': '可见级别',
|
||||
'warningEnabled': '是否启用预警',
|
||||
'warningStatus': '预警状态',
|
||||
'warningRed': '红色预警',
|
||||
'warningYellow': '黄色预警',
|
||||
'manual_link': '说明书链接',
|
||||
'manual_link_remark': '说明书链接备注',
|
||||
'product_image': '产品图片',
|
||||
'product_image_remark': '产品图片备注',
|
||||
'purchase_link': '采购链接',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@ -310,19 +361,81 @@ HIDDEN_SNAPSHOT_FIELDS = frozenset({
|
||||
'arrival_image_embedding',
|
||||
'qc_report_image_embedding',
|
||||
'image_embedding',
|
||||
'password', # ★ 见下方凭据关键词的说明
|
||||
'password_hash',
|
||||
})
|
||||
|
||||
# 凭据类字段的**关键词**拦截。
|
||||
#
|
||||
# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着
|
||||
# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)——
|
||||
# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。
|
||||
# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。
|
||||
#
|
||||
# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password,
|
||||
# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 ——
|
||||
# 新表加了凭据字段也自动被挡住。
|
||||
_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 7. 快照(details)的键与展示标题
|
||||
#
|
||||
# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生:
|
||||
# {'created': {...}} —— ORM 监听器的 INSERT 快照
|
||||
# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照
|
||||
# {'payload': {...}} —— 接口层手工记录的业务数据整包
|
||||
# (借库/出库/采购入库等,内部还嵌 items 数组)
|
||||
# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。
|
||||
#
|
||||
# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 ——
|
||||
# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。
|
||||
#
|
||||
# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。
|
||||
# =============================================================================
|
||||
SNAPSHOT_CREATED = 'created'
|
||||
SNAPSHOT_DELETED = 'deleted_snapshot'
|
||||
SNAPSHOT_PAYLOAD = 'payload'
|
||||
CHANGES_KEY = 'changes'
|
||||
|
||||
SNAPSHOT_VIEWS = (
|
||||
(SNAPSHOT_CREATED, '新增数据快照'),
|
||||
(SNAPSHOT_DELETED, '删除前数据快照'),
|
||||
(SNAPSHOT_PAYLOAD, '业务数据'),
|
||||
)
|
||||
|
||||
|
||||
def is_hidden_credential_field(key):
|
||||
"""
|
||||
该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示",
|
||||
还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。
|
||||
|
||||
★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。
|
||||
技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**,
|
||||
必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。
|
||||
"""
|
||||
k = str(key or '').strip().lower()
|
||||
return any(w in k for w in _CREDENTIAL_KEYWORDS)
|
||||
|
||||
|
||||
def is_hidden_snapshot_field(key):
|
||||
"""
|
||||
该字段是否属于「不该在详情快照里展示」的纯技术字段。
|
||||
该字段是否属于「不该在详情快照里展示」的字段。
|
||||
|
||||
★ 除名单本身,还按**后缀**拦截 `*embedding`:向量列在各表命名不一
|
||||
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
|
||||
逐个登记必然漏。新表加向量列时不必再改这里。
|
||||
三类:
|
||||
· 名单内的纯技术字段(id / 时间戳 / 审计元数据)
|
||||
· `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一
|
||||
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
|
||||
逐个登记必然漏。新表加向量列不必再改这里。
|
||||
· 凭据类字段 —— 见 is_hidden_credential_field
|
||||
|
||||
★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField):
|
||||
后端名单管"点名"的,关键词两端各自判,语义一致。
|
||||
"""
|
||||
k = str(key or '').strip().lower()
|
||||
return k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding')
|
||||
if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'):
|
||||
return True
|
||||
return is_hidden_credential_field(k)
|
||||
|
||||
|
||||
def field_label(key):
|
||||
@ -676,4 +789,8 @@ def labels_payload():
|
||||
'moduleDisplay': module_display_map(),
|
||||
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
|
||||
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
|
||||
# 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。
|
||||
# 前端据此把 created / deleted_snapshot / payload 三种存法抹平,
|
||||
# 不硬编键名。
|
||||
'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user