工程文章

云端 Mac CI 中审计 iOS 动态库依赖与导出符号

云端 Mac CI 中审计 iOS 动态库依赖与导出符号

一个 iOS 工程在开发分支上可以正常编译,Archive 里却可能悄悄多出动态 Framework、指向开发机目录的绝对链接,或暴露原本只打算内部使用的全局符号。工程文件审查很难覆盖这些变化,因为真正交付的是链接完成后的 Mach-O。更稳妥的做法,是让云端 Mac CI 直接检查归档产物,并把依赖与符号变化变成需要审核的差异。

先定义要拦截的变化

这类门禁不应该判断某个库“好或坏”,而应回答三个可验证的问题:

  1. 主程序和内嵌 Framework 实际依赖了什么?
  2. @rpath 指向的自带库是否真的存在于 App 包内?
  3. 新增的全局符号是否属于预期接口?

基线不是永久白名单,而是一次经过人工确认的产物快照。工具链升级、依赖更新或接口调整后可以更新基线,但必须和代码变更一起审查。

建议把检查放在 Release Archive 之后。Debug 构建可能包含诊断库和不同的优化结果,不能代表最终交付边界。首次落地时只生成报告,不立即阻断;等团队确认基线内容后,再把差异改为失败状态。

从 Archive 提取检查对象

先生成独立归档,避免复用开发者本机的历史产物:

rm -rf out/App.xcarchive
xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath "$PWD/out/App.xcarchive" \
  clean archive

如果签名由后续独立任务处理,可按工程实际情况设置 CODE_SIGNING_ALLOWED=NO。不要把这个覆盖项机械加入所有项目;包含特定能力或自定义构建阶段的工程,应先验证无签名归档是否完整。

归档中的主程序通常位于 Products/Applications。检查范围至少包括 App 主可执行文件和 Frameworks 目录内每个 Framework 的可执行文件。扩展目标也应独立纳入,因为它们拥有自己的依赖边界。

对象 主要检查 常见异常
App 主程序 系统库、自带 Framework、全局符号 非系统绝对路径
动态 Framework 二级依赖、安装名、公开符号 依赖未随包交付
App Extension 独立依赖与符号基线 误复用主程序假设

生成依赖与符号清单

下面的脚本读取主程序和内嵌 Framework,分别输出稳定排序的依赖与全局符号。排序可以避免工具输出顺序变化制造无意义差异。

set -euo pipefail

archive="${1:?archive path required}"
app=$(find "$archive/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)
test -n "$app"

mkdir -p audit/current
main=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Info.plist")
printf '%s
' "$app/$main" > audit/current/binaries.txt

if test -d "$app/Frameworks"; then
  while IFS= read -r framework; do
    executable=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$framework/Info.plist")
    printf '%s
' "$framework/$executable"
  done < <(find "$app/Frameworks" -maxdepth 1 -name '*.framework' -type d | sort)
fi >> audit/current/binaries.txt

: > audit/current/dependencies.txt
: > audit/current/symbols.txt

while IFS= read -r binary; do
  name=${binary#"$app/"}
  otool -L "$binary" |
    sed '1d' |
    awk -v n="$name" '{$1=$1; print n "	" $1}' \
    >> audit/current/dependencies.txt

  xcrun nm -gU "$binary" |
    awk -v n="$name" 'NF {print n "	" $NF}' \
    >> audit/current/symbols.txt
done < audit/current/binaries.txt

LC_ALL=C sort -u -o audit/current/dependencies.txt audit/current/dependencies.txt
LC_ALL=C sort -u -o audit/current/symbols.txt audit/current/symbols.txt

提交基线时只保留相对对象名,不要记录云端 Mac 的工作目录。流水线随后执行:

diff -u audit/baseline/dependencies.txt audit/current/dependencies.txt
diff -u audit/baseline/symbols.txt audit/current/symbols.txt

把报告变成可靠门禁

仅做全文差异会比较敏感。更实用的策略是先执行硬规则,再检查基线变化。依赖以 /System/Library/Frameworks//usr/lib/ 或受控的 @rpath/ 开头通常合理;其他绝对路径应直接失败。对于 @rpath/Example.framework/Example,脚本还应确认包内存在对应 Framework,不能只因为前缀正确就放行。

符号变化则适合人工审核。Swift 名称经过修饰后可能很长,不要凭字符串长度判断风险。重点查看突然出现的大批全局符号、调试辅助接口、测试入口,以及本应隐藏却跨模块可见的 C 或 Objective-C 符号。

降低误报

固定 Xcode 版本与 Release 配置,并确保每次比较的是同一 scheme。若项目包含多个扩展,为每个二进制保存独立基线,而不是把所有结果混成一个集合。工具链升级造成系统符号变化时,应在升级合并请求中更新基线,避免日后无法解释差异来源。

失败后按产物反查

出现新依赖时,先用 otool -L 确认由哪个二进制引入,再检查目标的链接设置、条件编译和依赖嵌入阶段。若 @rpath 目标缺失,重点核对 Framework 是否只参与链接却没有复制进包。出现意外符号时,可用 nm -gU 定位对象,再检查可见性声明、桥接代码和链接器导出配置。

不要在失败后直接覆盖基线。正确顺序是确认变更来源、判断是否影响交付边界、修复或批准变化,最后才重新生成基线。这样建立的门禁不依赖某台云端 Mac 的临时状态,也能为每次发布留下可复核的二进制边界记录。

常见问题

为什么不能只检查工程里的依赖声明?

工程声明不等于最终产物。条件编译、链接参数和依赖脚本都可能改变 Archive,发布门禁应直接检查最终生成的 Mach-O 文件。

导出符号发生变化就一定要阻止发布吗?

不一定。门禁的作用是要求人工确认变化;升级工具链或主动调整公共接口时,可以审核差异后更新并提交基线。

符号清单可以用来发现写进二进制的密钥吗?

不能单独依赖符号清单。它适合发现接口和链接边界变化,密钥检测还应覆盖源码、配置文件、资源文件与构建日志。

独享物理节点

把开发与构建任务放到独享云端 Mac

按任务选择机型、节点区域和租用周期。每份实例对应独享物理机,是非虚拟机环境。

选择云端 Mac 方案