HexVM 工程实践

在云端 Mac 上搭建 iOS 隐私清单合规门禁

在云端 Mac 上搭建 iOS 隐私清单合规门禁

团队把 iOS 工程迁到云端 Mac 后,构建通过并不代表隐私声明完整。依赖升级可能新增 PrivacyInfo.xcprivacy,资源复制设置也可能让主工程的清单没有进入最终 App。更稳妥的做法,是把隐私清单当成构建产物的一部分:先检查源码,再检查生成物,最后用版本化基线拦截未经审查的变化。

先定义门禁检查范围

一条可执行的门禁至少覆盖四层:清单能否被 plist 解析、顶层字段类型是否正确、所有依赖清单是否进入检查范围、最终 App 中是否存在预期清单。不要只在仓库根目录查一个文件,源码依赖、预编译框架和包管理器检出的组件都可能各自携带声明。

先在干净工作区建立清单索引:

find . \
  -path './.git' -prune -o \
  -path './DerivedData' -prune -o \
  -name PrivacyInfo.xcprivacy -print \
  | LC_ALL=C sort > privacy-manifests.current

建议把项目明确维护的路径写入 privacy-manifests.baseline。流水线比较两个文件;出现新增、删除或路径变化时先失败,由代码所有者确认后再更新基线。这样不会把依赖更新造成的声明变化静默吞掉。

基线不是合规证明。它只证明当前变化经过了显式审查,不能替代对代码真实行为的判断。

校验 plist 与顶层结构

plutil -lint 能发现 XML 或二进制 plist 损坏,但不会替你判断字段类型。可以用 Python 标准库增加一层轻量结构检查,不需要安装额外依赖:

import pathlib
import plistlib
import sys

allowed = {
    "NSPrivacyTracking": bool,
    "NSPrivacyTrackingDomains": list,
    "NSPrivacyCollectedDataTypes": list,
    "NSPrivacyAccessedAPITypes": list,
}

failed = False
files = sorted(pathlib.Path(".").rglob("PrivacyInfo.xcprivacy"))

if not files:
    print("No privacy manifest found")
    sys.exit(1)

for path in files:
    try:
        with path.open("rb") as stream:
            data = plistlib.load(stream)
        if not isinstance(data, dict):
            raise TypeError("Root must be a dictionary")
        for key, value in data.items():
            expected = allowed.get(key)
            if expected is None:
                raise KeyError(f"Unknown top-level key: {key}")
            if not isinstance(value, expected):
                raise TypeError(f"{key} must be {expected.__name__}")
        print(path)
    except Exception as error:
        failed = True
        print(f"{path}: {error}")

sys.exit(1 if failed else 0)

把脚本保存为 Scripts/validate_privacy_manifests.py,在构建前执行。若项目使用了组织内部扩展字段,不应直接放宽所有未知键,而应逐项加入允许列表并记录用途。

建立 Required Reason API 复核表

结构正确仍不等于理由正确。文件时间戳、系统启动时间、磁盘空间和偏好设置等调用都应进入代码复核范围。自动扫描可以提供线索,但宏、封装层和二进制依赖会产生漏报或误报,因此不要让一次文本搜索直接决定理由代码。

为每个声明维护一行可审计记录:

检查项 应记录内容 失败条件
API 类别 清单中的类别标识 类别无对应代码路径
使用位置 模块、文件与负责人 无法定位调用来源
使用目的 面向用户的实际功能 理由与行为不一致
依赖来源 自有代码或具体组件 二进制来源不明确
复核触发 代码或依赖版本变化 变更后未重新确认

对于闭源二进制组件,至少记录组件版本、清单哈希和引入它的业务功能。无法解释来源的声明不应靠猜测补齐,应回到依赖负责人处确认。

在最终 App 中再次检查

源码中的文件可能因 target membership 或资源阶段配置错误而未被复制。流水线应完成一次无签名 Release 构建,然后检查真实产物:

rm -rf .build/privacy

xcodebuild \
  -scheme "$SCHEME" \
  -configuration Release \
  -sdk iphoneos \
  -derivedDataPath .build/privacy \
  CODE_SIGNING_ALLOWED=NO \
  build

APP_PATH="$(find .build/privacy/Build/Products -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"
find "$APP_PATH" -name PrivacyInfo.xcprivacy -print | LC_ALL=C sort

主 App、嵌入框架和扩展应按工程预期分别出现。不要写死 DerivedData 的随机目录,也不要只检查源码数量,因为多个源码清单可能在构建过程中被合并、替换或遗漏。

保存产物清单

将产物内每个清单的相对路径和 SHA-256 保存为流水线附件:

find "$APP_PATH" -name PrivacyInfo.xcprivacy -print0 |
  while IFS= read -r -d '' file; do
    relative="${file#"$APP_PATH"/}"
    digest="$(shasum -a 256 "$file" | awk '{print $1}')"
    printf '%s  %s
' "$digest" "$relative"
  done | LC_ALL=C sort > privacy-artifact.sha256

发生审核问题时,这份记录可以回答“当次构建实际包含了什么”,比仅查看当前分支可靠。

把失败条件接入日常流程

建议将检查拆成快速与完整两档。合并请求先运行清单发现、plist 解析、字段类型和基线差异;主分支再执行 Release 构建与 App 产物复核。快速检查失败时通常几十秒内就能给出路径,完整检查则负责发现资源阶段和嵌入框架问题。

上线前保留以下检查项:

  • 清单索引与核准基线一致。
  • 每个文件均通过 plutil -lint 和结构脚本。
  • 新增 API 类别已关联代码位置、负责人和真实用途。
  • 依赖升级后的清单差异已经人工复核。
  • 最终 App、扩展与嵌入框架中的清单符合预期。
  • 产物路径和哈希已随本次构建归档。

这套门禁的目标不是自动替团队作出合规判断,而是让遗漏变成明确的构建失败,让每次声明变化都能追溯到代码、依赖和审查记录。

常见问题

项目里存在 PrivacyInfo.xcprivacy 就足够了吗?

不够。还要确认它能被解析、字段类型正确,并且实际进入最终应用或对应框架的构建产物。

自动脚本能判断每个 Required Reason API 理由是否正确吗?

不能完全判断。脚本适合发现缺失、格式错误和未审查变更,理由是否符合真实调用场景仍需代码所有者复核。

依赖升级后为什么要重新生成隐私清单基线?

依赖可能新增或修改自己的隐私清单。只有在审查差异并确认声明与代码行为一致后,才应更新基线。

独享 Apple Silicon

为下一次构建选择独享云端 Mac

按工作负载选择 Mac mini 机型、租期与区域。每笔订单对应独立物理节点,实际可用性以控制台实时返回为准。

选择云端 Mac 方案