先看结论与判断条件

  • 配置基线记录的是某个候选如何生成及适用哪些边界,不是“强度档位”的截图;每个字段都要有稳定名称、类型、所有者和变更理由。
  • 发布变体必须包含 build type、product flavor、applicationId、版本、ABI、minSdk、targetSdk 与签名责任,因为同仓库不同 variant 不是同一发布对象。
  • 工具二进制、规则架构、输入应用、依赖锁、保留规则和 Native 规则都应保存版本或 SHA-256,只有可读名称无法发现内容漂移。
  • 保护目标、排除项和运行策略要分别记录;每个排除项必须有原因、owner、风险、到期或复审条件,禁止永久无主例外。
  • 基线不得包含私钥、keystore 口令、API token、客户数据或内部密钥材料,只保存允许公开或受控审计的证书摘要和责任引用。
  • 规范化配置摘要必须进入候选来源证明、验收报告与发布批准;摘要一致支持可追溯,不代表具体防护强度或运行兼容通过。

配置基线要回答如何生成,而不是展示选中了什么

软件加固控制台通常有保护模式、规则文件、排除项、运行策略和签名选项。截图只能说明某个时刻界面显示了什么,无法稳定表达默认值、隐藏参数、规则正文、工具版本和输入产物。真正的配置基线应是一份结构化、可规范化、可计算摘要的记录,能够让审核者解释某个候选使用了哪些输入与边界。

基线的目标有三个:复现同一生成条件、比较两个候选的配置差异、让验收与发布证据挂到正确配置。它不是把敏感实现全部公开,也不是保存一份“最强参数模板”。需要保密的规则正文可以存放在受控仓库,基线只记录内容摘要、版本和责任引用,既能核对漂移又不泄露密钥或内部细节。

NIST SP 800-218 SSDF 要求安全发布保留来源、构建、验证和变更证据,并关注供应链风险。配置基线是其中的项目级证据之一,但 SSDF 是组织实践框架,不定义御盾或任何产品功能。本文没有具体候选与验证回执,因此只定义字段和门禁,不声称某组配置具备更高防护强度。

配置基线的四类问题
类别需要回答合格记录不足记录
身份哪个工具和输入版本、摘要与来源任务名称
范围保护和排除什么对象、模式、原因与 owner全量保护
行为运行策略如何响应触发、处置与边界开启防护
责任谁审批和发布角色、身份、时间与范围已确认
证据候选怎样绑定配置规范化摘要与声明控制台截图

发布变体和应用身份必须完整固定

Android build variants 说明 build type、product flavor、source set、applicationId 和签名配置会组合成不同变体。同一个仓库的 release、staging、不同渠道或地区 flavor 可能包含不同 SDK、资源、Manifest、证书和运行行为。配置基线必须指定准确 variant,不能只写“Android 正式版”。

变体字段至少包括 buildType、flavors、applicationId、versionCode、versionName、minSdk、targetSdk、compileSdk、abiFilters 和输出格式。iOS 或其他平台要采用等价的目标、bundle identity、构建配置、架构和最低系统字段。跨平台字段可以共享 schema,但不能用 Android 的概念强行填充 Apple 产物。

输入应用文件还要计算 SHA-256,并记录它是未签名、中间签名还是最终发布输入。加固前产物变化会使全部旧配置证据失去直接适用性,即使 variant 名称没有变化。源码提交和构建任务可辅助追溯,但只有输入文件摘要能够确认处理的是哪个字节对象。

发布变体最小字段
字段为什么需要变化影响核对来源
buildType/flavors定位真实配置组合SDK、资源和规则变化构建系统
applicationId/version定位发布序列安装、升级与渠道变化最终 Manifest
minSdk/targetSdk界定平台行为API 与兼容边界变化构建产物
abiFilters界定 Native 范围打包和设备矩阵变化构建配置与 APK
inputSha256固定加固输入字节全部候选证据需重建实际输入文件

工具、规则架构和依赖材料都要有不可变身份

工具名称和营销版本不足以复现配置。基线应记录 toolName、toolVersion、binarySha256、ruleSchemaVersion、runnerImageSha256 和执行模式。若工具由托管服务执行,至少保存服务版本、任务类型、受控配置导出和生成回执。相同显示版本下的二进制变化仍应触发差异审阅。

输入材料还包括应用产物、依赖锁、映射文件、保留规则、Native 规则、资源配置和相关策略。每个材料记录 logicalName、sha256、sourceReference 与 sensitivityClass。对规则正文只保存摘要不能支持完全复现时,受控仓库还要保存不可变版本;公开交接只展示摘要和授权引用。

SLSA Provenance v1.1 把构建证明与产物主体、构建者、构建类型、外部参数和依赖材料绑定。配置基线可以作为 external parameters 或专门材料被来源证明引用。provenance 只能说明记录的构建过程,不能单独证明候选运行兼容或安全效果。

工具与材料身份字段
对象身份字段存储位置漂移处置
加固工具版本与 binarySha256工具登记表重建候选与复测
规则架构schemaVersion配置仓库迁移并审阅字段
依赖锁lockSha256构建证据库重新计算影响
保留与 Native 规则内容摘要与版本受控规则仓库差异审阅和回归
执行环境runner image 或服务版本来源证明确认可复现边界

保护目标要按对象、模式和依赖关系表达

“开启代码保护”没有说明哪些包、类、方法、Native 库或资源被处理,也没有说明采用何种模式。基线应把 targets 写成稳定对象选择器,记录 objectType、selector、mode、owner、rationale 和 requiredTests。选择器必须能在候选生成前解析成明确清单,并保存解析结果摘要。

目标范围要与业务资产和兼容边界对应。支付、授权、算法或协议代码可能有不同保护需求,反射框架、序列化模型和第三方入口又需要兼容策略。工程判断应记录为什么某个对象进入某种模式,而不是把所有内容统一提高档位。更大范围不自动代表更强或更合适,仍要接受启动、体积、性能和运行回归。

OWASP MASVS-RESILIENCE 把抗篡改与抗逆向视为移动端纵深防御控制。它可以帮助定义控制目标,却不证明某个配置达到任何强度,也不能替代服务端授权、密钥管理和完整发布链。基线中的 resilienceControl 只记录声明与测试路由,不应写成绝对阻断承诺。

保护目标记录结构
字段示例对象决策用途禁止写法
objectTypepackage、class、method、native-lib选择正确处理器核心代码
selector受控命名或清单引用解析实际范围全部
mode已定义配置模式 ID固定处理行为最高强度
rationale业务资产与威胁边界支持审批安全需要
requiredTests启动、反射、JNI 或性能生成回归路由测试一下

排除项和保留规则必须有责任人与复审条件

排除项经常为修复一次崩溃临时加入,随后永久留在配置中。基线必须把 exclusions 与 targets 分开,每项记录 selector、excludedModes、reason、owner、riskAcceptedBy、createdAt、reviewAt 或 expiresAt、linkedIssue 和 requiredRegression。没有 owner 或理由的排除项应阻断批准。

排除范围要最小化并能解释实际失败条件。一个反射入口需要保留类名,不代表整个 SDK 或应用包都应跳过保护;一个 Native 库出现兼容问题,也不代表所有 SO 使用相同例外。配置审查应比较解析后的对象数量与上版变化,出现意外扩大时要求重新说明。

到期并不意味着自动删除规则,而是触发复审。复审要使用当前应用、依赖、工具和候选重新验证失败是否仍存在,决定缩小、保留或移除。旧问题单已关闭不等于运行问题消失,必须有对应设备回执;同样,删除排除项后静态构建成功也不能代替真实路径回归。

排除项治理字段
字段作用缺失风险门禁
selector/modes界定例外范围整包意外排除解析清单差异
reason/linkedIssue记录失败依据无法复核历史证据路径存在
owner/riskAcceptedBy确定技术与商业责任永久无主例外身份与权限有效
reviewAt/expiresAt触发定期复审历史债务沉积到期阻断新发布
requiredRegression关闭兼容风险只看构建结果真实路径回执

R8、保留规则和加固配置要分层记录

Enable app optimization with R8 说明 R8 负责代码与资源缩减、优化和名称混淆。R8 与软件加固可能都改变代码形态,但职责、输入与输出不同。基线应记录 R8 是否启用、规则摘要、mapping 摘要、资源缩减状态和流水线顺序,再单独记录加固规则,避免把两层配置混成一个“混淆开关”。

消费者 keep rules、应用规则和临时诊断规则的来源要区分。升级 SDK 可能改变 consumer rules,进而改变加固输入;调试时添加的 keep all 若残留,也可能让候选看似兼容却显著改变保护与优化范围。每层规则保存内容摘要、优先级、来源和解析输出,候选变更后重新生成 mapping 与清单。

R8 编译优化不等于 VMP,也不证明抗动态分析能力。反过来,加固处理也不能修复错误 keep rules、缺失反射入口或资源裁剪问题。基线的 pipelineOrder 用于定位责任:先确认未加固 release 变体的优化结果,再确认加固候选在同一输入和路径上的变化。

代码变换层的基线分工
记录内容主要责任证据
编译器版本与编译标志源码到目标代码构建来源
R8启用状态、规则、mapping缩减、优化与名称混淆规则与输出摘要
资源处理shrink 状态与保留配置资源裁剪资源清单差异
加固目标、模式、排除和运行策略声明的保护变换规范化配置摘要
签名责任与证书摘要发布身份签名验证回执

审批、密钥边界和变更记录同样属于基线

配置完成后需要技术 owner、兼容测试、安全审核和发布责任按各自范围批准。approval 记录包含 role、identity、approvedConfigSha256、scope、decision、approvedAt 与 evidenceReference。审批人不能只签任务号,因为任务可能重新执行;其决定必须引用规范化配置摘要和预期输入摘要。

基线绝不能保存 password、token、privateKey、keystore 文件、恢复码、客户数据或内部密钥材料。签名部分只记录 expectedCertificateSha256、signingResponsibility 和受控密钥系统引用。配置生成器应扫描常见敏感字段并失败关闭,输出用于审计的 JSON 也要按 sensitivityClass 控制访问。

变更记录要说明 fromConfigSha256、toConfigSha256、changedFields、reason、owner、影响门禁和新候选。控制台默认值或工具升级导致的隐式变化也属于变更,不能因为没人手工编辑就忽略。审批只对明确摘要有效,配置或输入任一变化后,旧批准应进入需评估状态。

审批与敏感信息边界
对象允许记录禁止记录变化处置
审批角色、身份、摘要、范围和时间口头同意重新评估受影响范围
证书公开 SHA-256 与责任私钥签名身份复核
密钥系统受控引用和回执 IDtoken 与口令权限审计
规则内容摘要和仓库版本不必要的公开正文差异审查
诊断数据脱敏回执引用客户数据和真实凭据隔离与最小保留

把规范化摘要与候选证明绑定并用代码校验

in-toto Attestation Statement v1 可以把产物 subject 摘要与有类型 predicate 绑定。配置基线可作为 hardening-configuration predicate,其中包含 configSha256、inputArtifactSha256、variant、tool identity 和审批引用。声明格式不保证内容真实,仍要核对生成者、签名、schema 与候选文件。

下方脚本读取配置 JSON 和候选文件,拒绝敏感字段、未知顶层字段、无摘要材料、无 owner 排除项和不完整变体;将有序集合排序后生成规范化 JSON 和 configSha256,再输出以候选 SHA-256 为 subject 的声明。它不修改候选、不接触密钥,也不会声称配置强度或运行兼容。

规范化摘要相同只表示脚本可见字段相同;若工具存在未导出的默认值、远端策略或执行环境漂移,仍需来源证明补足。最终证据包应保存 schema、脚本摘要、原始配置、规范化摘要、候选、构建来源、验证回执和批准。准备实际配置治理方案时,可通过御盾中央平台提交申请。

配置基线证据链
证据主键支持结论不能支持
原始配置sourceConfigSha256输入记录未变字段完整
规范化配置configSha256可比较的语义快照隐藏默认值相同
候选文件artifactSha256实际产物身份运行兼容
attestationsubject 与 predicateType配置和候选关联声明内容真实
验收与批准同一 config 和 artifact覆盖范围内的决定未知能力成立
  • 配置 schema、平台、variant、工具和全部输入材料具有稳定身份
  • targets 与 exclusions 分离,均含 owner、理由和 requiredTests
  • 保留规则、Native 规则、依赖锁和输入应用以 SHA-256 记录
  • 审批引用规范化 configSha256 与明确范围,不只引用任务号
  • 配置不包含私钥、口令、token、客户数据或恢复材料
  • 配置摘要与实际候选 subject、来源证明和验证回执绑定
规范化软件加固配置并绑定候选摘要
#!/usr/bin/env python3
import copy
import hashlib
import json
import re
import sys
from pathlib import Path

SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
FORBIDDEN_KEYS = {
    "password", "passwd", "token", "apiKey", "secret",
    "privateKey", "keystorePassword", "keyPassword", "recoveryCode",
}
ALLOWED_TOP_LEVEL = {
    "schemaVersion", "platform", "variant", "tool", "materials",
    "targets", "exclusions", "runtimePolicies", "signing", "approvals",
}
SORTED_LIST_FIELDS = {
    "abiFilters", "flavors", "requiredTests", "excludedModes",
    "runtimePolicies", "resolvedObjects",
}

def fail(message, code=2):
    print(message, file=sys.stderr)
    raise SystemExit(code)

def sha256_bytes(value):
    return hashlib.sha256(value).hexdigest()

def sha256_file(path):
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()

def load_config(path):
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        fail("cannot read configuration: " + str(exc))
    if not isinstance(value, dict):
        fail("configuration must be a JSON object")
    return value

def require_text(record, field, context):
    value = record.get(field)
    if not isinstance(value, str) or not value.strip():
        fail(context + " is missing " + field)
    return value.strip()

def reject_secrets(value, path="$"):
    if isinstance(value, dict):
        for key, child in value.items():
            if key in FORBIDDEN_KEYS:
                fail("forbidden sensitive field: " + path + "." + key, 3)
            reject_secrets(child, path + "." + key)
    elif isinstance(value, list):
        for index, child in enumerate(value):
            reject_secrets(child, path + "[" + str(index) + "]")

def validate_sha(value, context):
    if not isinstance(value, str) or not SHA256_RE.fullmatch(value.lower()):
        fail(context + " must be a SHA-256 digest")
    return value.lower()

def validate_variant(variant):
    if not isinstance(variant, dict):
        fail("variant must be an object")
    for field in ("buildType", "applicationId", "versionCode", "versionName", "minSdk", "targetSdk"):
        require_text(variant, field, "variant")
    for field in ("flavors", "abiFilters"):
        if not isinstance(variant.get(field), list):
            fail("variant." + field + " must be an array")

def validate_tool(tool):
    if not isinstance(tool, dict):
        fail("tool must be an object")
    for field in ("name", "version", "ruleSchemaVersion"):
        require_text(tool, field, "tool")
    validate_sha(tool.get("binarySha256"), "tool.binarySha256")

def validate_materials(materials):
    if not isinstance(materials, list) or not materials:
        fail("materials must be a non-empty array")
    names = set()
    for record in materials:
        if not isinstance(record, dict):
            fail("every material must be an object")
        name = require_text(record, "logicalName", "material")
        if name in names:
            fail("duplicate material logicalName: " + name)
        names.add(name)
        validate_sha(record.get("sha256"), name + ".sha256")
        require_text(record, "sourceReference", name)
        require_text(record, "sensitivityClass", name)

def validate_targets(targets):
    if not isinstance(targets, list) or not targets:
        fail("targets must be a non-empty array")
    for index, record in enumerate(targets):
        context = "target[" + str(index) + "]"
        if not isinstance(record, dict):
            fail(context + " must be an object")
        for field in ("objectType", "selector", "mode", "owner", "rationale"):
            require_text(record, field, context)
        if not isinstance(record.get("requiredTests"), list) or not record["requiredTests"]:
            fail(context + " requiredTests must be a non-empty array")

def validate_exclusions(exclusions):
    if not isinstance(exclusions, list):
        fail("exclusions must be an array")
    for index, record in enumerate(exclusions):
        context = "exclusion[" + str(index) + "]"
        if not isinstance(record, dict):
            fail(context + " must be an object")
        for field in ("selector", "reason", "owner", "riskAcceptedBy", "linkedIssue", "reviewAt"):
            require_text(record, field, context)
        if not isinstance(record.get("excludedModes"), list) or not record["excludedModes"]:
            fail(context + " excludedModes must be a non-empty array")

def validate_approvals(approvals):
    if not isinstance(approvals, list) or not approvals:
        fail("approvals must be a non-empty array")
    for index, record in enumerate(approvals):
        context = "approval[" + str(index) + "]"
        if not isinstance(record, dict):
            fail(context + " must be an object")
        for field in ("role", "identity", "scope", "decision", "approvedAt", "evidenceReference"):
            require_text(record, field, context)

def normalize(value, parent_key=""):
    if isinstance(value, dict):
        return {key: normalize(value[key], key) for key in sorted(value)}
    if isinstance(value, list):
        normalized = [normalize(item) for item in value]
        if parent_key in SORTED_LIST_FIELDS and all(isinstance(item, str) for item in normalized):
            return sorted(set(normalized))
        if all(isinstance(item, dict) and "logicalName" in item for item in normalized):
            return sorted(normalized, key=lambda item: item["logicalName"])
        return normalized
    return value

def main():
    if len(sys.argv) != 3:
        print("Usage: baseline_hardening_config.py CONFIG_JSON CANDIDATE_FILE", file=sys.stderr)
        raise SystemExit(2)
    config_path = Path(sys.argv[1])
    candidate_path = Path(sys.argv[2])
    if not config_path.is_file() or not candidate_path.is_file():
        print("configuration and candidate files must exist", file=sys.stderr)
        raise SystemExit(2)
    config = load_config(config_path)
    unknown = sorted(set(config) - ALLOWED_TOP_LEVEL)
    if unknown:
        fail("unknown top-level fields: " + ", ".join(unknown))
    reject_secrets(config)
    require_text(config, "schemaVersion", "configuration")
    require_text(config, "platform", "configuration")
    validate_variant(config.get("variant"))
    validate_tool(config.get("tool"))
    validate_materials(config.get("materials"))
    validate_targets(config.get("targets"))
    validate_exclusions(config.get("exclusions"))
    validate_approvals(config.get("approvals"))
    if not isinstance(config.get("runtimePolicies"), list):
        fail("runtimePolicies must be an array")
    signing = config.get("signing")
    if not isinstance(signing, dict):
        fail("signing must be an object")
    validate_sha(signing.get("expectedCertificateSha256"), "signing.expectedCertificateSha256")
    require_text(signing, "responsibility", "signing")

    normalized = normalize(copy.deepcopy(config))
    canonical = json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    config_sha = sha256_bytes(canonical)
    candidate_sha = sha256_file(candidate_path)
    input_material = next((item for item in normalized["materials"] if item["logicalName"] == "input-application"), None)
    if input_material is None:
        fail("materials must include input-application")
    statement = {
        "_type": "https://in-toto.io/Statement/v1",
        "subject": [{"name": candidate_path.name, "digest": {"sha256": candidate_sha}}],
        "predicateType": "https://example.invalid/hardening-configuration/v1",
        "predicate": {
            "configSha256": config_sha,
            "sourceConfigSha256": sha256_file(config_path),
            "inputArtifactSha256": input_material["sha256"],
            "schemaVersion": normalized["schemaVersion"],
            "platform": normalized["platform"],
            "variant": normalized["variant"],
            "tool": normalized["tool"],
            "boundary": "Configuration identity does not prove protection strength or runtime compatibility.",
        },
    }
    print(json.dumps(statement, ensure_ascii=False, indent=2, sort_keys=True))

if __name__ == "__main__":
    main()

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
安全发布应保留来源、构建、验证和变更证据并关注供应链风险。NIST SP 800-218 SSDF 给出组织级安全软件开发实践。SSDF 不定义某个 App 加固产品的具体功能或配置强度。
构建证明可以绑定产物主体、构建者、构建类型、外部参数和依赖材料。SLSA Provenance v1.1 定义来源证明的数据模型。provenance 只能支持记录的构建过程,不能单独证明运行时安全。
供应链声明可以把产物摘要与有类型的配置负载关联。in-toto Attestation Statement v1 定义 subject 和 predicateType。声明格式不保证内容真实,仍需可信生成者、签名与门禁核验。
R8 负责代码和资源缩减、优化与名称混淆,发布构建需保留规则和输出。Enable app optimization with R8 说明 Android 应用优化流程。R8 编译优化不等于 VMP,也不证明抗动态分析能力。
build type、product flavor、source set、applicationId 和签名配置会组成不同变体。Android build variants 说明 Android 构建变体模型。同一仓库不代表所有 variant 拥有相同 SDK、资源、证书和行为。
移动端抗篡改与抗逆向属于纵深防御控制。OWASP MASVS-RESILIENCE 描述移动端韧性控制域。控制目录不证明某个候选或配置达到任何防护强度。
保护目标、排除项、运行策略、材料和审批应进入规范化配置摘要。工程判断:这些字段共同决定候选生成边界,缺失时无法解释配置漂移。字段与摘要完整只支持可追溯,不证明实际运行与保护效果。
排除项必须有 owner、理由、风险接受者和复审条件。工程判断:无主永久例外会扩大未受控范围并使后续变更无法审计。复审到期不代表应自动删除,仍需当前候选与真实路径验证。

工程常见问题

保存软件加固控制台截图能否作为配置基线?

不能单独作为基线。截图难以表达默认值、隐藏参数、规则内容和工具身份;应导出结构化配置,规范化生成摘要,并保存截图作为辅助证据。

为什么同一仓库不同 product flavor 要分别记录?

flavor 可能改变 SDK、资源、Manifest、applicationId、证书和规则。它们是不同发布对象,不能共享一个含糊的“release 配置”身份。

配置基线是否应该保存 keystore 和密码以便复现?

不应该。基线只记录证书摘要、签名责任和受控密钥系统引用,私钥、keystore 口令、token 与恢复材料应留在专门密钥系统。

configSha256 相同是否说明候选一定相同?

不说明。输入应用、工具二进制、依赖、执行环境或隐藏默认值仍可能变化,因此还要比较材料摘要、候选 SHA-256 和构建来源。

排除项到期后是否应该自动删除?

到期应触发复审,不宜直接删除。需要在当前工具、依赖和候选上重现原失败并执行回归,再决定缩小、保留或移除。

建立软件加固配置基线要准备哪些材料?

准备发布变体、输入应用、工具与环境身份、依赖锁、保护目标、排除项、规则摘要、运行策略、签名责任、审批角色和验证计划,再通过御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: 御盾 VMP 接入前准备清单