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:
@ -36,7 +36,10 @@ from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.utils.audit_labels import (
|
||||
BOOLEAN_FIELDS,
|
||||
CHANGES_KEY,
|
||||
PERSON_NAME_FIELDS,
|
||||
SNAPSHOT_CREATED,
|
||||
SNAPSHOT_DELETED,
|
||||
USER_ID_FIELDS,
|
||||
action_label,
|
||||
bool_label,
|
||||
@ -44,6 +47,7 @@ from app.utils.audit_labels import (
|
||||
enum_label,
|
||||
field_label,
|
||||
id_ref_of,
|
||||
is_hidden_credential_field,
|
||||
module_display,
|
||||
person_name_label,
|
||||
)
|
||||
@ -54,10 +58,8 @@ SYSTEM_USERNAME = 'system'
|
||||
# 未解析出实体时的占位
|
||||
UNKNOWN = '-'
|
||||
|
||||
# details 里的三种结构各自的键名 —— 集中在此,避免调用方记错
|
||||
SNAPSHOT_CREATED = 'created'
|
||||
SNAPSHOT_DELETED = 'deleted_snapshot'
|
||||
CHANGES_KEY = 'changes'
|
||||
# details 的键名统一在 audit_labels.py 定义(那里还要下发给前端),
|
||||
# 此处只做引用 —— 两个模块各存一份字符串就是在等着它们漂移。
|
||||
|
||||
# 变更字段的噪声过滤。
|
||||
#
|
||||
@ -695,6 +697,11 @@ def changes_of(row):
|
||||
for key, val in changes.items():
|
||||
if key in IGNORED_CHANGE_FIELDS:
|
||||
continue
|
||||
# ★ 凭据类字段一律不出现在任何出口(详情、摘要、Excel 导出)。
|
||||
# 在这里滤是源头做法 —— 逐个出口去补必然漏掉某一个,
|
||||
# 而漏掉的那个就是泄漏点。
|
||||
if is_hidden_credential_field(key):
|
||||
continue
|
||||
if isinstance(val, dict):
|
||||
old, new = val.get('old'), val.get('new')
|
||||
else:
|
||||
@ -708,6 +715,25 @@ def changes_of(row):
|
||||
return out
|
||||
|
||||
|
||||
def snapshot_of_any(row):
|
||||
"""
|
||||
该行的业务快照 + 它的展示标题,按 SNAPSHOT_VIEWS 的优先级取第一个可用的。
|
||||
|
||||
返回 (title, data) 或 (None, None)。data 可能是 dict(常规)也可能是
|
||||
list(`payload` 实测有整包就是数组的),由调用方按类型处置。
|
||||
"""
|
||||
from app.utils.audit_labels import SNAPSHOT_VIEWS
|
||||
|
||||
details = getattr(row, 'details', None)
|
||||
if not isinstance(details, dict):
|
||||
return None, None
|
||||
for key, title in SNAPSHOT_VIEWS:
|
||||
data = details.get(key)
|
||||
if data:
|
||||
return title, data
|
||||
return None, None
|
||||
|
||||
|
||||
def snapshot_of(row, which):
|
||||
"""
|
||||
CREATE / DELETE 记录的整字段快照 → {字段: 值};结构异常时返回 {}。
|
||||
@ -723,16 +749,53 @@ def snapshot_of(row, which):
|
||||
snap = details.get(which)
|
||||
if not isinstance(snap, dict):
|
||||
return {}
|
||||
return {k: v for k, v in snap.items() if k not in IGNORED_CHANGE_FIELDS}
|
||||
return {
|
||||
k: v for k, v in snap.items()
|
||||
if k not in IGNORED_CHANGE_FIELDS
|
||||
# 凭据类同样在源头滤掉(理由见 changes_of)
|
||||
and not is_hidden_credential_field(k)
|
||||
}
|
||||
|
||||
|
||||
def sanitize_details(details):
|
||||
"""
|
||||
details(含嵌套快照与数组)→ 剔除凭据类字段后的**新**结构。
|
||||
|
||||
★★ 必须在这层做,不能只靠前端隐藏:前端隐藏只是"不显示",原始响应里
|
||||
照样有明文 —— 任何人打开 devtools 就读得到。
|
||||
**凡是要挡的数据,都要在接口出口剥掉。** 前端那份只是为了不把
|
||||
DOM 也渲染出来(双保险),不是防线本身。
|
||||
|
||||
★ 实测来源:`用户管理/新增` 的 payload 快照里存着明文密码(27 条),
|
||||
写入路径把请求体整包记进了审计 —— 请求体里是哈希前的原始密码。
|
||||
|
||||
★ 递归进 dict 与 list:payload 里嵌着 items 数组,数组元素对象上同样
|
||||
可能有凭据字段,只看第一层会漏。
|
||||
|
||||
★ 返回新对象,**不原地改** ORM 对象持有的 dict —— 那会污染 session
|
||||
里的实体,同一个请求后续若还要用到原始数据就会拿到被改过的版本。
|
||||
"""
|
||||
if isinstance(details, dict):
|
||||
return {
|
||||
k: sanitize_details(v)
|
||||
for k, v in details.items()
|
||||
if not is_hidden_credential_field(k)
|
||||
}
|
||||
if isinstance(details, list):
|
||||
return [sanitize_details(v) for v in details]
|
||||
return details
|
||||
|
||||
|
||||
def has_detail_content(details):
|
||||
"""details 里是否有可展示的结构(与前端 hasDetailContent 同判据)"""
|
||||
if not isinstance(details, dict) or not details:
|
||||
return False
|
||||
return any(
|
||||
details.get(k) for k in (CHANGES_KEY, SNAPSHOT_DELETED, SNAPSHOT_CREATED, 'payload')
|
||||
)
|
||||
# 键名取自 SNAPSHOT_VIEWS + changes —— 与前端渲染用的是同一份,
|
||||
# 不在这里另列一遍(漏一个就会出现"有详情按钮但点开是空的")
|
||||
from app.utils.audit_labels import SNAPSHOT_VIEWS
|
||||
|
||||
keys = [k for k, _ in SNAPSHOT_VIEWS] + [CHANGES_KEY]
|
||||
return any(details.get(k) for k in keys)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -932,21 +995,30 @@ def changes_summary(row, ref_maps, limit=200, resolved=None, material=None):
|
||||
mod = module_display(row.module)
|
||||
prefix = f"{verb}({mod}):" if mod else f"{verb}:"
|
||||
|
||||
for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED):
|
||||
snap = snapshot_of(row, which)
|
||||
if not snap:
|
||||
continue
|
||||
# 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的
|
||||
core = _core_attrs(snap, material)
|
||||
if core:
|
||||
return truncate(prefix + '、'.join(core), limit)
|
||||
# 回落:没有核心属性可提(如 BOM 记录),退化成字段名清单。
|
||||
# 跳过 id:主键每条都有,排最前却零信息量,白占位置。
|
||||
keys = [k for k in snap if k != 'id']
|
||||
names = [field_label(k) for k in keys[:8]]
|
||||
more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段"
|
||||
return truncate(f"{prefix}{'、'.join(names)}{more}", limit)
|
||||
return ''
|
||||
# ★ 用 snapshot_of_any:`payload` 是与 `created` 平级的第三种快照存法
|
||||
# (借库/出库/采购入库走这条),只看 created/deleted_snapshot 会让
|
||||
# 这些记录的新增摘要整列为空。
|
||||
_title, snap = snapshot_of_any(row)
|
||||
if not isinstance(snap, dict):
|
||||
return ''
|
||||
# 优先:核心属性的**值**(多少数量、放哪)—— 这才是用户想知道的
|
||||
core = _core_attrs(snap, material)
|
||||
if core:
|
||||
return truncate(prefix + '、'.join(core), limit)
|
||||
# 回落:没有核心属性可提(如 BOM 记录、payload 型的借库单),
|
||||
# 退化成字段名清单。跳过 id(主键每条都有、零信息量)与凭据类字段
|
||||
# (摘要里出现「password」这种字样本身就是多余的暴露)。
|
||||
keys = [k for k in snap
|
||||
if k != 'id' and not is_hidden_credential_field(k)]
|
||||
names = []
|
||||
for k in keys[:8]:
|
||||
v = snap[k]
|
||||
# 数组字段带上条数 —— 只写「物料明细」看不出有几行,
|
||||
# 「物料明细 1 项」才是有用的信息
|
||||
names.append(f"{field_label(k)} {len(v)} 项" if isinstance(v, list)
|
||||
else field_label(k))
|
||||
more = '' if len(keys) <= 8 else f" 等 {len(keys)} 个字段"
|
||||
return truncate(f"{prefix}{'、'.join(names)}{more}", limit)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@ -55,8 +55,6 @@ from app.models.transaction import TransBorrow
|
||||
from app.services.audit_export_service import (
|
||||
DAILY_REPORT_HEAD,
|
||||
EXCEL_SNAPSHOT_COL_LIMIT,
|
||||
SNAPSHOT_CREATED,
|
||||
SNAPSHOT_DELETED,
|
||||
SYSTEM_USERNAME,
|
||||
UNKNOWN,
|
||||
cell,
|
||||
@ -71,7 +69,11 @@ from app.services.audit_export_service import (
|
||||
snapshot_headers,
|
||||
snapshot_of,
|
||||
)
|
||||
from app.utils.audit_labels import canon_action
|
||||
from app.utils.audit_labels import (
|
||||
SNAPSHOT_CREATED,
|
||||
SNAPSHOT_DELETED,
|
||||
canon_action,
|
||||
)
|
||||
from app.utils.constants import OutboundType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Reference in New Issue
Block a user