💡 完整可运行示例可在 GitHub 上获取:
sanitize-office-document-pii-python

在点击发送前没人审查的数据

一份季度董事会报告会发送给外部审计员。文本毫无瑕疵;经过三轮审阅确保了这一点。文件本身却是另一番景象。它的属性仍然记录着起草该文件的分析师、重新编辑的经理、拥有模板的公司子公司、截止日期前一晚的 LastPrinted 时间戳,以及内部签署工作流中的 SharePoint 审批人 ID。这些信息并未出现在任何页面上,却随文件一起流转。

PII 移除是一个针对 Python(通过 .NET)的 GroupDocs.Metadata 工作流,能够以编程方式剥离 Word、Excel 和 PowerPoint 文件中携带身份信息的属性。本文比较了 API 提供的三种方法:基于标签的身份字段移除、基于名称模式的属性族移除(如评论和修订),以及一次性调用 sanitize() 清除所有内容。你还会看到大多数清理脚本会跳过的步骤——一次验证扫描,用以证明清理确实生效。

为什么元数据 PII 需要独立的流水线

内容审查工具检查人们阅读的内容,却不检查文件系统存储的内容,这一缺口正是合规事故的来源。GDPR 请求同样涉及 Author 和 Manager 字段中的个人数据,就像涉及正文一样。法律发现会读取修订计数器和编辑时间总计,以重建立场文件的协商时长。投标审查员可以通过 SharePoint 工作流属性绘制你的组织结构,新闻稿的评论字段则会保留审阅者姓名以及草稿阶段的备注。每一项都是发现点,但它们都不出现在文档正文中。

先决条件

在开始之前,请确保你具备:

  • Python 3(带 pip)
  • GroupDocs.Metadata for Python via .NET,示例仓库固定在 26.5 版本
  • 一个带有真实属性的 Office 文件,用于练习

安装

pip install groupdocs-metadata-net==26.5

companion repository 会提供一个示例 DOCX 并将下面的每段代码作为已断言的流水线运行。

方法 1:基于标签的身份信息移除

最敏感的四个字段——Author、LastSavedBy、Manager 和 Company——在不同的 Office 格式中拥有不同的内部名称。标签系统解决了这个问题:与其按名称指定属性,不如让谓词请求所有标记为人或公司的属性。

# Match identity properties by meaning, not by format-specific name
with Metadata("board-report.docx") as metadata:
    removed = metadata.remove_properties(lambda p:
        Tags.person.creator in list(p.tags)     # Author, LastSavedBy
        or Tags.person.editor in list(p.tags)
        or Tags.person.manager in list(p.tags)
        or Tags.corporate.company in list(p.tags))
    metadata.save("board-report-clean.docx")

print(f"{removed} identity properties removed")

Key points:

  • 格式独立性:同一个 lambda 能清理 DOCX、XLSX 和 PPTX,因为标签是按角色分类的。
  • 可计数的结果remove_properties 返回匹配的属性数量,可写入审计日志。
  • 复制语义:保存到新路径可保留原始文件以供记录。

💡 Tip:此步骤会保留 Title、Subject 等描述性字段,使文件仍然友好于搜索和 DMS 索引。

方法 2:基于名称模式的属性族移除

标签覆盖已分类的概念。泄漏字段的整族往往位于标签未覆盖的范围:评论属性、修订计数、SharePoint 工作流标记。对于这些,需要直接匹配属性名称本身。

# Comment fields often live in custom properties the tag system
# does not classify, so match them by name substring
with Metadata("board-report.docx") as metadata:
    removed = metadata.remove_properties(lambda p:
        p.name is not None and (
            "Comment" in p.name
            or "Reviewer" in p.name
            or "Reviewed" in p.name))
    metadata.save("board-report-no-comments.docx")

相同的结构可处理另外两个族;只需更改子串列表:

匹配子串
修订轨迹 Revision, TrackedChange, LastPrinted, TotalEditingTime, EditTime
服务器 / 工作流 Server, Workflow, Approver, ContentType, Template

这是一种以覆盖面换取精确度的做法:"Comment" 也会匹配 CommentsCommentCount,这正是大多数清理过程想要的。宽泛的子串可能会匹配到无害的模板字段,因此请将返回计数与预期进行比对。

💡 Tip:当审计日志需要按类别计数时,可将每个族单独运行;若不需要,则将所有子串合并到一个谓词中。

方法 3:一次性完整清理

当文件离开组织且元数据层不应保留任何信息时,直接调用一次性清理即可。

# One call, every detected metadata package
with Metadata("board-report.docx") as metadata:
    removed = metadata.sanitize()
    metadata.save("board-report-final.docx")

print(f"sanitize() removed {removed} properties")

sanitize() 会清除库检测到的所有包:文档信息身份字段、评论、修订历史、已跟踪更改的作者以及自定义 OOXML 部分。其行为在 Clean metadata 页面有文档说明。它的强大也是代价所在:Title 和 Subject 会随 PII 一起消失,因此应在导出关口使用,而非协作流程中途使用。

我是否需要所有四个针对性的步骤?

不需要。每个步骤的存在是因为不同团队负责不同风险。身份字段困扰隐私官,评论轨迹困扰法务,修订计数困扰谈判方,服务器字段困扰安全团队。根据你的审阅者需求以任意顺序运行相应步骤,因为每一步都会生成自己的输出副本。当没有任何字段需要保留时,直接跳到 sanitize() 并进行验证即可。

三种方法对比

方法 适用场景 主要优势 局限性
Tag-driven removal 工作副本、多格式流水线 格式独立,保留描述性字段 仅覆盖标签分类的概念
Name-pattern removal 评论、修订、服务器字段 能触及标签未覆盖的自定义属性 子串需根据环境调优
Full sanitize() 组织外部的最终导出 不会遗漏任何遗忘的属性 会清除无害字段

这些方法可以自然组合:文档活跃期间使用针对性步骤,导出时使用 sanitize()

在信任之前先验证

仅凭返回的计数并不能证明文件已清洁。仓库在每次运行结束时会重新打开已清理的输出,并使用 find_properties 进行扫描,谓词结合了上述所有步骤的标签规则和名称规则。

def is_pii(p):
    if p.name is None:
        return False
    return (
        Tags.person.creator in list(p.tags)
        or Tags.person.editor in list(p.tags)
        or Tags.person.manager in list(p.tags)
        or Tags.corporate.company in list(p.tags)
        or any(n in p.name for n in (
            "Comment", "Reviewer", "Revision", "TrackedChange",
            "Classification", "Department", "Server", "Workflow")))

with Metadata("board-report-final.docx") as metadata:
    for p in metadata.find_properties(is_pii):
        value = (str(p.interpreted_value) if p.interpreted_value is not None
                 else (str(p.value) if p.value is not None else ""))
        if value and value not in ("0", "0.0"):
            print(f"LEAK {p.name}={value}")

仓库中的完整版本会将残留分为两类,这一点很重要。元数据泄漏必须为零。内容层面的残余(如 Word 注释气泡和 word/document.xml 中的已跟踪更改)属于正文内容,元数据 API 无法触及;需要使用如 Aspose.Words 之类的内容编辑库来移除。诚实的报告会列出这两类,而不是在第一类清零后就宣称胜利。第一次在“干净”文件上运行此扫描时,它标记出了一个部门字段——该字段被公司模板悄悄重新添加了数月。

最佳实践与技巧

  • Sanitize copies, never originals:此处的每段代码都会写入新路径,保留原始文件以满足记录和保留规则。
  • Log the countsremove_propertiessanitize() 的返回值即为审计轨迹。请按文件、按步骤存储它们。
  • Wire verification into CI:在构建失败时进行泄漏检查,可在模板回归的当天捕获问题,而不是等到客户发现时才处理。
  • Mind the metadata/content boundary:当正文层面的评论仍然存在时,切勿报告文件已清洁;应将其作为单独的发现报告。
  • Licensing:评估模式会复现本文中的所有内容;生产环境请使用正式许可证,以免评估标记出现在外发文件中。

结论

三种方法,一条决策规则。概念已被标签分类且文件需保持可用时使用标签匹配;属性族位于自定义属性时使用名称匹配;文件跨越信任边界时调用 sanitize(),并通过回读扫描进行验证,无论走哪条路线。

准备深入了解吗?以下是后续步骤:

附加资源

如有疑问或想分享实现,请前往 support forum