Published on

否定式声明的脆弱性:为什么"App 不嵌入 X"会随一次依赖变更悄悄变成谎言

Authors
  • avatar
    Name
    Jack Qin
    Twitter

合规声明里有一类特别危险的形态:否定式声明——"本 App 不嵌入 Sentry、Firebase 或任何第三方分析/广告/行为追踪 SDK"。它危险,不是因为写错了,而是因为它的真伪不由它自己决定,而由别处的代码决定。代码这边加一行依赖,声明那边一个字没动,它就从"真"翻成"假"——而且这个翻转在改动那一侧是完全看不见的

本文不讲某次合规事故怎么补救,而是想拆清楚一笔结构账:为什么否定式声明的真伪锚定在代码上、为什么"代码改了、声明没改"是这类声明的默认失效路径而非偶然疏忽、以及为什么唯一可靠的对策是把代码变更和声明变更绑成一个不可部分部署的原子单元。理解这笔账,你面对的就不只是一个隐私政策,而是一整类"声明的真伪由别处状态决定"的合规/文档问题。


现象:一行依赖让一句声明撒了谎

移动端隐私政策页里曾有这么一句字面声明:

The App does not embed Sentry, Firebase, or any third-party analytics, advertising or behavioural-tracking SDK.

然后某个任务里,一个只改移动端的 diff 装上了 @sentry/react-native。问题就此成形:这个 diff 一旦发版,上面那句话立刻变成假的——而这个矛盾从移动端的 review 视角完全看不见,只有当 reviewer 恰好想起去翻隐私政策页时才会暴露。

注意这个失效的不对称性:让声明变假的动作(加依赖)和声明本身,处在两个不同的文件、不同的 review 视野里。改 package.json 的人看到的是依赖列表,看不到那句声明;写声明的人当初也无法预知未来某次依赖变更会推翻它。这不是谁粗心,而是否定式声明的结构使然——它的真伪锚定在一个它自己无法观测、也无法约束的外部状态(依赖图)上。


为什么这是真问题,而非仅"前后矛盾"

风险不止"声明对不上"这一层,它有三条独立的下游后果:

  1. App Store「App Privacy」答案和 Google Play「Data Safety」表单都是从已发布的隐私政策誊抄的。 Apple 的审核会、也确实会拿声明的数据流去和线上政策 URL 交叉核对——不一致是一个有记录的拒审原因
  2. 澳大利亚隐私法 APP 8(个人信息的跨境披露):一旦有任何个人信息被路由给境外处理方,它就触发一个主动披露义务。发版后才发现这个义务,是不可挽回的——数据已经流出去了。
  3. 否定列表声明会悄无声息地过期。 "App 不嵌入 X"这类声明的失效不会报错、不会触发任何告警,它只是在某次依赖变更后静静地变成假话。

第二条尤其点出了为什么时机重要:合规义务在数据流真实发生的那一刻就成立了,不是在你想起更新文档的那一刻。SDK 一接好、构建一跑,数据流就是真的——政策有没有跟上,不改变义务已经触发的事实。


触发条件:什么时候这笔账必须算

不是每次依赖变更都触发。决定性的判据是"是否引入或扩大了对用户/设备数据的处理与外发":

  • apps/mobile/package.json 加一个会与第三方源建立网络连接、并处理用户/设备数据的 SDK(Sentry、Firebase 全家桶、PostHog、Mixpanel、Amplitude、Segment、Branch、AppsFlyer、OneSignal、Adjust、LogRocket……);
  • 现有 SDK 的数据范围实质性扩大(打开 sendDefaultPii、加 Sentry.setUser、启用 Replay、加 attribution 事件);
  • 现有处理方的数据驻留地/区域变了;
  • 新增一个 gate SDK 初始化的 EAS secret / EXPO_PUBLIC_* 值。

触发:仅后端 SDK 改动(apps/api 有自己的披露面)、仅 web SDK 改动(web 政策单独覆盖)、从不发网络请求的本地库(date-fns、lodash、主题库等)。这条边界本身就是判据的核心——触发与否取决于数据有没有真的外发,不取决于包大小或重要程度。


对策:把代码与声明绑成不可部分部署的原子单元

既然失效路径是"代码改了、声明没跟上",对策就必须从机制上消灭"只改一半"的可能。手段是把所有相关改动塞进同一个 PR——因为 PR 不可能被部分部署,这是最简单的原子性强制。同一 PR 里要覆盖:

  1. apps/web/src/app/(others)/legal/privacy/index.tsx(web 政策)——更新诊断数据条目、把处理方加进第三方处理方列表、若传出澳大利亚则加进跨境列表;
  2. apps/web/src/app/(others)/legal/privacy/mobile/index.tsx(移动端专属政策)——即便它看起来"通用"也必须改,因为那条否定列表声明就住在这里,会悄悄过期;
  3. docs/legal/privacy-web.mddocs/legal/privacy-mobile.md——给法务 review 的 markdown 镜像;
  4. apps/web/src/app/(others)/legal/legal-pages.test.tsx——断言新处理方名 + 每条新披露子句出现在渲染后的页面上。

每个处理方的披露还必须写清:运营方法定名称(如「Functional Software, Inc. (Sentry)」,不只是品牌名)、传输的数据类别、用途、数据驻留/区域、是否传输个人账号信息(明确点名字段,如「移动端不传输 name、email 或 user identifier」)、以及跨境告知(APP 8,若数据离开澳大利亚)。


时序约束:声明必须先于数据流上线

原子 PR 解决了"改不全",但还有一个时序问题:声明的线上可见时间,必须早于数据流的真实发生时间。

web deploy(Cloudflare Pages,main merge 后自动触发)必须先于 SDK-enabled 的移动构建被推到任何测试渠道(TestFlight、Google Play Closed Testing、生产)。把改动捆在一个 PR 里之所以是最干净的强制,正是因为它同时解决了原子性和时序——一个 PR 无法被部分部署,也就无法让 SDK 抢在政策前面上线。

如果工作非要拆成多个 PR,时序就得手动守:隐私政策 PR 先发先部署,核验线上 URL 确实反映了新披露;移动 SDK PR 后发,包含该 SDK 的构建必须等线上 URL 反映披露之后才能开始它的 EAS Build。

这也直接定义了一组反模式,它们的共性都是破坏原子性或时序

  • 「隐私更新放后续 PR」——后续 PR 落地前,移动构建早进 TestFlight,政策一直错位;
  • 只更新 web 政策页——撒谎的恰恰是移动端政策页;
  • 只改 JSX 不改 markdown 镜像——法务读的是 markdown,不同步就漂移;
  • 跳过测试断言——没测试,下次重构会悄悄回归披露;
  • 先加 EAS secret + 插件配置、把政策改动延后——SDK 一接好、构建一跑,数据流就是真的。

可迁移的那一层

抛开隐私政策和 App Store 的具体流程,真正可迁移的认知有两条。

否定式声明("系统不做 X")的真伪由外部状态决定,因此它会随那个状态的任何变更而静默失效。 它和肯定式声明("系统会做 Y")的脆弱性不对称:肯定式声明你至少知道去哪儿维护,否定式声明的杀手是一个和声明毫无显式关联的远处改动。代码注释里的"这里永远不会为 null"、文档里的"本服务不存储 PII"、配置里的"未启用任何遥测"——全是同一类定时炸弹。对这类声明,唯一可靠的守卫是把"让它变假的动作"和"声明本身"在工具层面强制耦合:测试断言、原子提交、CI 检查,让声明无法在它的前提被推翻时还安然留在文档里。

当一个义务在"状态真实发生"的瞬间就成立时,文档与状态必须原子地、按正确时序一起变更。 数据一旦外发,合规义务就生效了,不管文档跟没跟上。把代码变更和声明变更绑进一个不可部分部署的单元,本质上是用工程手段保证"声明永远不落后于现实"。问自己一句:如果有人推翻了这条声明的前提,是否存在一个机制(而不只是一份记忆)会立刻让声明跟着变?