一个 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 隐私合规的工程落地 。
常见错误与边界
- 只统计权限个数:不同权限的伤害差异巨大,必须按能力加权。
- 把时机当性能选项:启动即初始化会绕过同意顺序,属于合规缺陷。
- 评审一次管终身:超过 180 天未复审的 SDK 应自动进入治理问题清单。
- 高分直接下线:核心功能组件应限制能力与时机,而不是一刀切移除。
- 只看新增权限:移除权限也要记录,否则无法解释能力收缩。
- 删依赖即宣布下线:缓存、披露、同意引用都可能有残留。
- 评分表藏在个人文档里:权重表要进仓库、走评审、可复算。
- 忽略未批准罚分:未评审本身就是风险,不能与已评审 SDK 同分比较。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。