☰
memlab 中 UnclassifiedLabelFamily 详解:堆快照匿名化报告里“仍留在明文中“的标签家族
2026/10/14 5:51:18 网站建设 项目流程
  • 开发工具
  • 测试
  • CLI
  • MCP 服务

【免费下载链接】memlab

A framework for finding JavaScript memory leaks and analyzing heap snapshots

项目地址:https://gitcode.com/gh_mirrors/me/memlab
点击查看免费下载

UnclassifiedLabelFamily是 memlab 堆快照匿名化模块(HeapAnonymizer)报告类型中的一个关键成员:它把匿名化之后仍然以明文保留在快照字符串表中的、具有相同机器生成形状(shape)的标签聚合成"家族"逐条报出,让调用者判断其中是否有本应用私有的标识符方案。理解这个类型,等于理解了 memlab 匿名化设计中最有特色的一环——工具不猜测你应用内部的 ID 格式,而是把"它没敢删、也认不出是什么"的部分点名列出,把决定权交还给调用者。读完本文,你将掌握该类型的每个字段的语义、其生成算法(shape 签名、过滤与分组)、它在 CLI 与 API 中的实际输出形态,以及如何用它与shouldRedact回调形成"检测—复核—二次匿名化"的完整闭环。

类型定义与字段语义

UnclassifiedLabelFamily定义于 HeapAnonymizer.ts,并从 core 包入口 导出:

export type UnclassifiedLabelFamily = { /** * character-class shape with runs collapsed, e.g. `d@a` for `4155551234@ex` */ shape: string; /** how many distinct labels in the string table share it */ count: number; /** up to three of them, verbatim, so the shape can be recognized */ examples: string[]; };

三个字段的含义(与 API 文档 UnclassifiedLabelFamily.md 完全一致):

字段类型含义
shapestring字符类形状签名,连续同类字符折叠为一个符号。例如4155551234@ex的形状是d@a;UUID 前缀7ac91e02-11bd-4c7f折叠成dada-dada-dada形态
countnumber字符串表中去重后有多少个不同的标签共享这一形状
examplesstring[]最多 3 个原文保留的样例,让读者能够凭肉眼认出这一形状对应什么

类型文档中有一句定性非常重要:"A family here is aprompt to look, not a finding"——d.d.d这个形状在一个应用里可能是版本号,在另一个应用里可能是账号 ID。所以报告只负责点名"这些形状没被归类",而不下"这是泄漏"的结论。这一设计动机在源码注释里写得很直白:

This is how the tool generalizes past its own pattern list. No fixed set of formats can know an identifier scheme private to one application, so rather than guess, the report names what it could not classify and lets the caller decide.

(HeapAnonymizer.ts L140-L148)

为什么需要"未分类家族":标签与内容的二分法

要理解UnclassifiedLabelFamily存在的原因,得先看匿名化器的总体判断依据。HeapAnonymizer.ts 文件头注释(L22-L59)说明了核心思路:堆快照的strings共享表中的每一项只有两种到达方式:

  • 作为string/concatenated string/sliced string节点的name——这是堆上 JS 字符串的内容(content),即数据,默认必须脱敏;
  • 作为边名称或对象/闭包/代码/原生节点的name——这是标签(label):类名、函数名、属性键。retainer trace(保留者链路)完全由标签构成。

因此"精确脱敏字符串内容"是一个类型测试而非启发式,脱敏后所有 trace、直方图、dominator 与 shape 分析依然可读。但注释同时点出两个节点类型判断覆盖不到的漏洞:

  1. 标签本身可能就是数据。以账号 ID、订单 ID、session ID 作为键的对象,会把用户标识符变成属性名——结构上是标签,语义上是载荷。节点类型测试无法区分.addListener和一个由数据库行生成的键。对符合互联网标准格式(长数字串、邮箱/handle、凭证等)的,用形状匹配脱敏;而对单个应用私有的标识符方案,任何固定模式列表都无法预知。unclassifiedLabelFamilies就是为这部分设计的"点名机制"。
  2. native节点名可能承载序列化 DOM(浏览器引擎写入的outerHTML描述等),由dom-text规则处理。

所以UnclassifiedLabelFamily的定位是:内置规则删掉了能删的,而这个报告告诉你还剩下什么形状可疑的东西——尤其是那些被刻意保留下来(因为看起来像代码标识符)的标签。

shape 签名算法:shapeSignature

shape字段由 shapeSignature 函数 生成,算法很短,但值得逐行理解:

function shapeSignature(value: string): string { let out = ''; let last = ''; const limit = Math.min(value.length, 48); // 只取前 48 个字符 for (let i = 0; i < limit; ++i) { const c = value[i]; let cls: string; if (c >= '0' && c <= '9') cls = 'd'; // 数字 -> d else if (c >= 'a' && c <= 'z') cls = 'a'; // 小写字母 -> a else if (c >= 'A' && c <= 'Z') cls = 'A'; // 大写字母 -> A else cls = c; // 其他字符原样保留 if (cls !== last) { // 连续同类折叠 out += cls; last = cls; } } return out; }

三个设计要点:

  • 连续折叠(runs collapsed):d415551234@ex这样的输入里,连续的d只输出一个。因此7ac91e02-11bd-4c7f与0f31ba77-92cd-4e10都会归约成dada-dada-开头的同一形状,而enableFastPath归约为aAaAa。
  • 分隔符原样保留:-、@、_等字符是"让一种生成格式变得可辨认"的关键,所以不折叠为泛符号。CLI 输出中也有同样的说明:a$d这一形状会同时覆盖require$$99、module$42及所有同类标识符。
  • 截断到 48 字符:长值(如序列化片段)的尾部不参与签名,避免形状被偶发尾部差异打散。

同一个签名函数还被用于RedactionContext.shape——即shouldRedact回调收到的上下文——这让调用者可以按形状匹配整个私有方案而不用写精确的正则,与报告的shape字段形成同一套语言。

分组与过滤:collectUnclassifiedLabelFamilies

报告条目由 collectUnclassifiedLabelFamilies 生成,它在每次匿名化(或审计)结束时被调用:

const MAX_REPORTED_FAMILIES = 12; // 最多报 12 个家族 const MAX_FAMILY_EXAMPLES = 3; // 每家族最多 3 个样例 function collectUnclassifiedLabelFamilies( strings: string[], isLabel: Uint8Array, contentRule: Int32Array, limit: number, ): UnclassifiedLabelFamily[] { const families = new Map<string, {count: number; examples: string[]}>(); const vocabularyShape = /^[aA_$]+$/; for (let i = 0; i < isLabel.length; ++i) { if (!isLabel[i] || contentRule[i] >= 0) continue; // 只看"仍是明文的标签" const value = strings[i]; if (value.length === 0 || /^\d+$/.test(value) || /^\d+ \/ /.test(value)) continue; const shape = shapeSignature(value); if (vocabularyShape.test(shape)) continue; // ... 按 shape 分组计数,保留前 3 个样例 } return [...families] .map(([shape, {count, examples}]) => ({shape, count, examples})) .sort((a, b) => b.count - a.count) .slice(0, limit); }

从源码结构看,入选条件可以被精确归纳为四条过滤,每一条都有明确的工程动机(注释见 L458-L476):

  1. 必须是标签且未被任何内容规则命中(isLabel[i]且contentRule[i] < 0)。也就是说,报告只覆盖"刻意留在明文"的词汇——字符串内容早已在之前的阶段被脱敏,不在此列。
  2. 排除纯数字串(/^\d+$/)。V8 会发出穷举式的数组下标名("0"…"9"、"10"…"99"……),源码注释提到某次真实捕获中这类标签有 133,226 个,不排掉会淹没一切。
  3. 排除保留者描述串(/^\d+ \/ /,形如N / part of key (...) -> value (...))。这是快照序列化器为描述 WeakMap 条目写的诊断文本,属于引擎产物而非应用数据。
  4. 排除纯源码词汇形状(/^[aA_$]+$/):只由字母、下划线、$构成的形状是任何语言里源码词汇的形状,把它们报出来只会让报告大部分是噪声。注释也说明这与脱敏规则的风险不对称:"unlike a redaction rule, being wrong here only costs a line of output"——这里误报的代价只是一行输出,所以宁可漏报纯词汇形状。

最后按count降序排列、截取前 12 个家族。注意count统计的是去重后的不同标签数量,不是引用次数——这一点与RedactionContext.labelUseCount(某条目在整个快照中被用作边名的次数)是有区别的两个信号:前者衡量"有多少个不同的键长得一样",后者衡量"同一个键被用了多少次"(程序员写的属性名会被复用,按记录铸造的标识符通常只用一两次)。

报告输出:AnonymizeReport 与 shouldRedact 的闭环

UnclassifiedLabelFamily[]作为AnonymizeReport.unclassifiedLabelFamilies存在(定义见 HeapAnonymizer.ts)。AnonymizeReport的设计口号是"anonymizeHeapSnapshot 做了什么——以及同样重要的——留下了什么"。unclassifiedLabelFamilies字段注释给出的行动建议是:

Review these: anything here that is an identifier in YOUR application is a residual leak, and the fix is to pass it as anextraPatternsentry.

与之配套的是AnonymizeOptions.shouldRedact——"对任何条目的最终裁决权,先于所有内置规则被咨询"。两个类型是同一机制的两端:

  • unclassifiedLabelFamilies让调用者看见自己应用私有方案长什么样(按形状 + 原文样例);
  • shouldRedact让调用者行动:返回true强制脱敏、false强制保护、undefined交回内置规则。

AnonymizeOptions 文档 中的官方示例正展示了这个闭环:

anonymizeHeapSnapshot(heap, { // this app keys caches by order id: ORD-<digits> shouldRedact: (value, ctx) => ctx.isLabel && value.startsWith('ORD-') ? true : undefined, });

测试用例 HeapAnonymizer.test.ts(见 L240-L290)验证了这条链路的每个环节:应用私有键ORD_kfmqvzhtbewx在默认配置下存活(因为内置规则不认识它),带长数字串的col_987654321012被digit-run规则移除——这构成回调存在的"正向对照";随后传入shouldRedact回调后,私有键被以规则名custom-callback计入contentRedactedByRule,同时断言回调收到的context.shape为'A_a',证明上下文确实携带了可用于按形状匹配整个方案的签名。

CLI 与审计入口中的实际形态

CLI 命令memlab heap anonymize(实现见 AnonymizeSnapshotCommand.ts)把家族列表渲染成表格。其输出逻辑值得注意的两个设计(L114-L149):即使列表为空也打印该小节并放在最后,因为"只列出删掉了什么的工具,会诱导读者假设剩下的都是安全的";并且行动建议放在列表之前,明确告诉读者"这些是属性名和标签,不是字符串值——其中凡是你应用里的标识符,就是残留泄漏,请用shouldRedact回调再次匿名化":

Still in the clear — review before sharing These are property names and labels, NOT string values — they were kept because they look like code identifiers. Any that is an identifier in YOUR application is a residual: anonymize again from the API with a `shouldRedact` callback. Grouped by SHAPE, where a=lowercase run, A=uppercase, d=digits — so "a$d" covers require$$99, module$42, and anything else like them. count shape examples 26303 a_a ORD_kfmqvzht..., ORD_82xw1q... 142 dada-dada- 7ac91e02-11bd..., 0f31ba77-92cd...

(表格数值为按源码输出格式示意;实际数值取决于你的捕获。)

命令行参数与示例见 CLI-commands.md 与命令的getExamples()(L165-L171):

--snapshot <HEAP_SNAPSHOT_FILE> --output <OUTPUT_FILE> --snapshot <HEAP_SNAPSHOT_FILE> --output <OUTPUT_FILE> --anonymize-salt <SALT> --snapshot <HEAP_SNAPSHOT_FILE> --audit-only

API 侧的对应入口有三个:

  • anonymizeHeapSnapshot:对已解析的IHeapSnapshot就地重写;
  • anonymizeHeapSnapshotFile:文件到文件,跳过构建对象图(源码注释指出,在 7.1M 节点的捕获上,跳过这一步意味着省掉约 15 秒与数 GB 内存),输出端流式写入;
  • auditHeapSnapshotFile:只报告将会移除什么、会留下什么,不写任何文件。其文档注释描述了一个真实的用途:拿它去审计别人已经匿名化过的快照——在一次这样的审计中,unclassifiedLabelFamilies暴露出对方删掉了所有字符串值、却把 26,303 个账号 handle 作为属性名留在了明文中。

MCP server 的 anonymize-snapshot 工具 也渲染同一份家族列表(Markdown 表格),并在工具描述中强调了这条"Always reports what it did NOT remove"的原则。

实践建议:把"未分类家族"当作审计清单

综合源码与测试,围绕UnclassifiedLabelFamily的可操作工作流是:

  1. 匿名化/审计后先看这份列表。按count降序取前几条,用examples中的原文对照自己应用的键命名方案(订单号、账号 handle、缓存键前缀等)。
  2. 确认是私有标识符后,两条修复路径任选:格式可正则化的,加extraPatterns(一次性、静态);需要按上下文判断的(例如只在isLabel && labelUseCount <= 2时脱敏,保护高频复用的正常属性名),用shouldRedact回调。回调的context.shape与家族列表的shape使用同一签名,可以直接把列表里的形状搬进回调做匹配。
  3. 复核:重新跑auditHeapSnapshotFile(或--audit-only),确认该家族消失、且没有误伤正常词汇。
  4. 注意边界:stable模式保持等值与长度,脱敏产物不会重新匹配内置规则(替换字母表只有小写字母,刻意不含数字);但长度信息被完整保留,shouldRedact返回false/keepPatterns保护的条目会同时豁免字符串值通道——这些契约在 anonymizeRawHeapSnapshot 的实现注释中都有对应说明,修改规则时值得对照。

小结

UnclassifiedLabelFamily只有三个字段,却浓缩了 memlab 匿名化器的核心工程哲学:内置规则只负责"互联网标准格式"这类可枚举的敏感形状;对任何单一应用私有的标识符方案,工具不猜——它通过shape(折叠字符类签名)把未归类标签聚合成最多 12 个家族、附上原文样例报出来,再交给shouldRedact与extraPatterns这两个调用者接缝去裁决。对需要把堆快照分享给同事、工单或模型的团队来说,这份"仍留在明文中"的清单(无论来自 CLI 表格、AnonymizeReport还是 MCP 工具输出)就是分享前必须逐行过的审计清单。相关类型与函数的完整 API 文档可在 core 包 API 索引 中继续查阅。

  • 开发工具
  • 测试
  • CLI
  • MCP 服务

【免费下载链接】memlab

A framework for finding JavaScript memory leaks and analyzing heap snapshots

项目地址:https://gitcode.com/gh_mirrors/me/memlab
点击查看免费下载
上一篇:AMD Ryzen处理器终极调试指南:SMUDebugTool完整使用教程
下一篇:AMD Ryzen处理器深度调试终极指南:SMUDebugTool完全解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询