《互联网产品的客户端架构·规模化与架构演进》8.2 第三方 SDK 治理

第三方 SDK 的风险不该靠印象打分。本节用可解释的权重表计算风险分,覆盖权限差异、初始化时机、治理问题清单与下线证据检查,并讨论评分阈值、例外审批,以及「删依赖不等于下线」的工程边界,给出可复算的准入与退出判据。评分只是入口,能否上线与能否下线都要落到证据。

一个 30 分的 SDK 是怎么进包的

某电商 App 在版本里接入广告联盟 SDK。接入评审只问了三句话:能不能带来收入、包体涨多少、会不会崩。三周后合规团队做盘点,发现这个 SDK 在启动阶段就初始化,申请定位与电话状态,采集广告标识、应用列表和位置,上次评审还是一年半以前。

把权限、数据、时机按同一张权重表算一遍,风险分是 30,落在高风险区。团队不是故意放行,而是从来没有一个统一的分数:商务看收入,客户端看包体,合规看到的是一份过期的表格。

清单先于评分

SDK 治理的第一步不是打分,而是把每个 SDK 的字段固定下来。字段不齐,分数只是另一种印象:

字段含义缺失的后果
permissions申请的系统权限无法评估能力面
dataTypes采集的数据类型无法评估数据面
initTiming初始化时机无法判断是否早于同意
approved是否通过准入评审未评审的 SDK 混入
lastReviewed上次评审日期过期风险无人发现

初始化时机不是性能选项,而是合规顺序:

时机含义适用
app-start进程启动即初始化崩溃上报等无敏感数据能力
post-consent用户同意后初始化分析、推送、广告
first-use首次使用功能时初始化地图、分享
never保留清单但不初始化已决定下线

风险分要可复算

权重表是治理的核心资产,不是实现细节。权限、数据类型、时机各自有明确权重,未批准再罚五分,总分封顶一百:

// file: src/security/sdk-governance.ts
// tier: L1
// verify: node --test test/security/sdk-governance.test.ts

export type RiskLevel = "low" | "medium" | "high";
export type InitTiming = "app-start" | "first-use" | "post-consent" | "never";

export type SdkSpec = {
  id: string;
  name: string;
  vendor: string;
  permissions: string[];
  dataTypes: string[];
  initTiming: InitTiming;
  approved: boolean;
  lastReviewed: string;
};

export type RiskAssessment = { score: number; level: RiskLevel };
export type PermissionDiff = { added: string[]; removed: string[] };
export type InitPlan = {
  immediate: string[];
  afterConsent: string[];
  onDemand: string[];
  disabled: string[];
};

export type RemovalEvidence = {
  codeReferences: number;
  cachedKeys: string[];
  disclosureMentions: boolean;
  consentReferences: number;
};

export type RemovalCheck = { ok: boolean; blockers: string[] };

const PERMISSION_WEIGHTS: Record<string, number> = {
  camera: 2, microphone: 2, photos: 1, location: 4, contacts: 5,
  calendar: 2, bluetooth: 1, notifications: 1, storage: 1, "phone-state": 3,
};

const DATA_WEIGHTS: Record<string, number> = {
  "device-id": 2, "ad-id": 4, location: 4, contacts: 5,
  "phone-number": 4, "app-list": 4, clipboard: 3, "usage-events": 1,
};

const TIMING_WEIGHTS: Record<InitTiming, number> = {
  "app-start": 6,
  "first-use": 2,
  "post-consent": 0,
  never: 0,
};

const UNAPPROVED_PENALTY = 5;
const SENSITIVE_TOKENS = new Set(["location", "contacts", "ad-id", "phone-number"]);

export const SDKS: SdkSpec[] = [
  {
    id: "crash-reporter", name: "崩溃上报", vendor: "Vendor A",
    permissions: [], dataTypes: ["device-id", "usage-events"],
    initTiming: "app-start", approved: true, lastReviewed: "2026-09-10",
  },
  {
    id: "push-gateway", name: "推送网关", vendor: "Vendor B",
    permissions: ["notifications"], dataTypes: ["device-id"],
    initTiming: "post-consent", approved: true, lastReviewed: "2026-08-01",
  },
  {
    id: "product-analytics", name: "行为分析", vendor: "Vendor C",
    permissions: [], dataTypes: ["usage-events", "device-id"],
    initTiming: "post-consent", approved: true, lastReviewed: "2026-09-20",
  },
  {
    id: "map-widget", name: "地图组件", vendor: "Vendor D",
    permissions: ["location"], dataTypes: ["location"],
    initTiming: "first-use", approved: true, lastReviewed: "2026-07-15",
  },
  {
    id: "ad-network", name: "广告联盟", vendor: "Vendor E",
    permissions: ["location", "phone-state"],
    dataTypes: ["ad-id", "app-list", "location"],
    initTiming: "app-start", approved: false, lastReviewed: "2026-05-01",
  },
  {
    id: "social-share", name: "社交分享", vendor: "Vendor F",
    permissions: ["contacts", "photos"], dataTypes: ["contacts", "device-id"],
    initTiming: "first-use", approved: true, lastReviewed: "2026-10-01",
  },
];

export function permissionWeight(name: string): number {
  return PERMISSION_WEIGHTS[name] ?? 2;
}

export function dataTypeWeight(name: string): number {
  return DATA_WEIGHTS[name] ?? 2;
}

export function riskScore(sdk: SdkSpec): number {
  const permissions = sdk.permissions.reduce((sum, name) => sum + permissionWeight(name), 0);
  const data = sdk.dataTypes.reduce((sum, name) => sum + dataTypeWeight(name), 0);
  const timing = TIMING_WEIGHTS[sdk.initTiming];
  const penalty = sdk.approved ? 0 : UNAPPROVED_PENALTY;
  return Math.min(100, permissions + data + timing + penalty);
}

export function riskLevel(score: number): RiskLevel {
  if (score <= 8) {
    return "low";
  }
  if (score <= 20) {
    return "medium";
  }
  return "high";
}

export function assessSdk(sdk: SdkSpec): RiskAssessment {
  const score = riskScore(sdk);
  return { score, level: riskLevel(score) };
}

export function permissionDiff(before: string[], after: string[]): PermissionDiff {
  const beforeSet = new Set(before);
  const afterSet = new Set(after);
  return {
    added: [...afterSet].filter((name) => !beforeSet.has(name)).sort(),
    removed: [...beforeSet].filter((name) => !afterSet.has(name)).sort(),
  };
}

export function permissionUnion(sdks: SdkSpec[] = SDKS): string[] {
  const union = new Set<string>();
  for (const sdk of sdks) {
    for (const permission of sdk.permissions) {
      union.add(permission);
    }
  }
  return [...union].sort();
}

export function initPlan(sdks: SdkSpec[] = SDKS): InitPlan {
  const plan: InitPlan = { immediate: [], afterConsent: [], onDemand: [], disabled: [] };
  const target: Record<InitTiming, keyof InitPlan> = {
    "app-start": "immediate",
    "post-consent": "afterConsent",
    "first-use": "onDemand",
    never: "disabled",
  };
  for (const sdk of sdks) {
    plan[target[sdk.initTiming]].push(sdk.id);
  }
  return plan;
}

export function reviewAgeDays(sdk: SdkSpec, today: string): number {
  const reviewed = Date.parse(`${sdk.lastReviewed}T00:00:00Z`);
  const current = Date.parse(`${today}T00:00:00Z`);
  return Math.floor((current - reviewed) / 86_400_000);
}

export function governanceIssues(
  sdks: SdkSpec[] = SDKS,
  today = "2026-10-31",
  maxAgeDays = 180,
): string[] {
  const issues: string[] = [];
  for (const sdk of sdks) {
    if (!sdk.approved) {
      issues.push(`${sdk.id}:not-approved`);
    }
    const age = reviewAgeDays(sdk, today);
    if (age > maxAgeDays) {
      issues.push(`${sdk.id}:stale-review:${age}`);
    }
    const sensitive = sdk.permissions.some((name) => SENSITIVE_TOKENS.has(name))
      || sdk.dataTypes.some((name) => SENSITIVE_TOKENS.has(name));
    if (sdk.initTiming === "app-start" && sensitive) {
      issues.push(`${sdk.id}:app-start-sensitive`);
    }
  }
  return issues;
}

export function sortByRisk(sdks: SdkSpec[] = SDKS): SdkSpec[] {
  return [...sdks].sort((a, b) => {
    const delta = riskScore(b) - riskScore(a);
    return delta !== 0 ? delta : a.id.localeCompare(b.id);
  });
}

export function removalChecklist(sdk: SdkSpec, evidence: RemovalEvidence): RemovalCheck {
  const blockers: string[] = [];
  if (evidence.codeReferences > 0) {
    blockers.push(`residual-code:${evidence.codeReferences}`);
  }
  if (evidence.cachedKeys.length > 0) {
    blockers.push(`residual-cache:${evidence.cachedKeys.join(",")}`);
  }
  if (!evidence.disclosureMentions) {
    blockers.push("disclosure-missing");
  }
  if (evidence.consentReferences > 0) {
    blockers.push(`consent-reference:${evidence.consentReferences}`);
  }
  return { ok: blockers.length === 0, blockers };
}

分数本身不解决问题,但它让评审有一个共同起点。两个人对同一个 SDK 算出不同分数,说明清单字段有歧义,应该先修清单。

测试与真实输出

✔ 已知权限权重按表格返回 (0.599583ms)
✔ 未知权限使用默认权重二 (0.063667ms)
✔ 已知数据类型权重按表格返回 (0.0585ms)
✔ 未知数据类型使用默认权重二 (0.049042ms)
✔ 推送 SDK 的评分等于权限加数据加时机 (0.144042ms)
✔ 广告联盟评分叠加未批准罚分 (0.416208ms)
✔ 未批准会额外增加五分 (0.061958ms)
✔ 评分封顶一百 (0.07875ms)
✔ 风险等级边界是八与二十 (0.070209ms)
✔ 权限差异分别给出新增与移除并排序 (0.1545ms)
✔ 权限未变化时差异为空 (0.063625ms)
✔ 初始化计划按时机分组 (0.089792ms)
✔ 初始化计划保持清单顺序 (0.078417ms)
✔ 永不初始化进入禁用组 (0.049291ms)
✔ 默认清单的治理问题集中在广告联盟 (0.1085ms)
✔ 合规 SDK 不产生治理问题 (0.048333ms)
✔ 评审时效按给定日期计算 (0.059041ms)
✔ 启动即初始化并触碰敏感数据会被标记 (0.060625ms)
✔ 非启动时机的敏感 SDK 不触发启动检查 (0.042792ms)
✔ 下线证据齐全时检查通过 (0.07125ms)
✔ 残留引用、缓存、披露与同意引用逐项列为阻塞 (0.038375ms)
✔ 仅缺隐私披露也阻止下线 (0.032334ms)
✔ 按风险从高到低排序并稳定处理并列 (7.835583ms)
✔ 权限并集去重并排序 (0.055958ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 134.165875

判据与反例

判据一:分数必须能手工复算

广告联盟的 30 分可以逐项对上:定位 4 分、电话状态 3 分、广告标识 4 分、应用列表 4 分、位置 4 分、启动时机 6 分、未批准 5 分。任何一项对不上,就说明权重表或实现有一边错了。

反例一:按权限个数打分

「申请了三个权限所以高风险」忽略了权限之间的差异。通讯录一个权限的伤害,可能大于通知、存储、蓝牙三个权限之和。评分要按能力分级,不是按数量。

判据二:初始化时机参与评分

同样的权限,启动即初始化和同意后初始化,合规风险完全不同。时机权重为 6,足以把一批「能力不大但抢跑」的 SDK 推进高风险区。

反例二:高分等于必须下线

评分高只说明需要更严格的评审,不等于立刻移除。地图组件 10 分,但它是核心功能;正确动作是保留、限制时机、补充披露。把评分当判决书,团队会学会绕过评分。

判据三:下线必须有证据

删掉依赖不等于下线。removalChecklist 检查四类残留:代码引用、缓存键、隐私披露、同意引用。任何一项不为零,都不能宣布完成。

权限差异要按版本看

SDK 升级时最危险的不是新增功能,而是权限静默扩张。permissionDiff 对升级前后做集合差,新增与移除分别排序输出。评审只看 added,因为移除通常意味着能力收缩。

差异要进发布检查:新增 contacts 或 location 必须重新走准入,不能因为「只是小版本升级」跳过。

评分阈值的定法

低、中、高三档的边界不是自然常数。本文用 8 与 20,是因为样例清单在这两个点上分组稳定:低风险是纯通知类,中风险是单一敏感能力,高风险是敏感能力叠加抢跑或未评审。

阈值应该由团队根据自身清单校准,并且写进评审规则。阈值一变,历史结论要重算,否则同一批 SDK 会在不同季度得到不同等级,没人知道该信哪个。

下线检查的四个证据

证据通过条件不通过的含义
代码引用为 0仍可能被初始化
缓存键为空旧数据仍留在设备
隐私披露已移除或改写政策仍在承诺采集
同意引用为 0仍会触发同意弹窗

四项都过,才可以把 SDK 从清单移到「已下线」。清单不要删,保留历史记录,下一次有人想接回来时能查到上次为什么下线。

小结

SDK 治理的抓手是清单、分数与证据,而不是一次性的接入评审:

  • 清单字段固定,评分才有可复算的基础;
  • 初始化时机是合规属性,必须进入评分;
  • 高分触发更严评审,不自动等于下线;
  • 下线以四类证据为准,删依赖只是第一步。

阅读导航:上一节:8.1 数据分级与最小权限 · 下一节:8.3 隐私合规的工程落地 。

常见错误与边界

  1. 只统计权限个数:不同权限的伤害差异巨大,必须按能力加权。
  2. 把时机当性能选项:启动即初始化会绕过同意顺序,属于合规缺陷。
  3. 评审一次管终身:超过 180 天未复审的 SDK 应自动进入治理问题清单。
  4. 高分直接下线:核心功能组件应限制能力与时机,而不是一刀切移除。
  5. 只看新增权限:移除权限也要记录,否则无法解释能力收缩。
  6. 删依赖即宣布下线:缓存、披露、同意引用都可能有残留。
  7. 评分表藏在个人文档里:权重表要进仓库、走评审、可复算。
  8. 忽略未批准罚分:未评审本身就是风险,不能与已评审 SDK 同分比较。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查