prasadnadkarni/mobilegate
GitHub: prasadnadkarni/mobilegate
MobileGate 是一个轻量级的 Android APK CI/CD 发布安全门禁,用 Go 编译为单一静态二进制,对 APK 进行确定性静态扫描并输出 PASS/BLOCKED 决策,聚焦高置信度阻断而非全量发现报告。
Stars: 0 | Forks: 0
# MobileGate
一个用于 CI/CD 的 Android APK 发布门禁。它只回答一个问题 —— **PASS** 或 **BLOCKED** —— 并列出失败的具体控制项。它不是发现报告,也不追求面面俱到。
单一的静态 Go 二进制文件,没有运行时依赖,不需要 JVM。纯静态分析:运行扫描不需要设备、不需要模拟器、不需要网络访问。
```
$ mobilegate app-release.apk
RELEASE STATUS: BLOCKED
apk: app-release.apk
mode: strict
score: 39/100 (secondary to the release status above)
Failed controls:
MG-003 — Plaintext sensitive storage (backup exposure) (1 finding)
[AndroidManifest.xml] android:allowBackup="true"
why it blocks: android:allowBackup="true" is set on ...
remediation: Set android:allowBackup="false" on unless ...
```
## 为什么这不等于 MobSF
MobSF 会说“这里有 150 个发现。”MobileGate 会说“你的发布被拦截了,因为这些控制项失败了。”这就是整个产品的区别,这是刻意为之的,而不是较小工具的局限性。
承诺的是精准,而不是覆盖。一个干净的 APK 必须产生零阻断性发现——这个特性本身*就是*产品。一个在干净应用上触发的阻断规则,比漏掉真实问题的规则是更严重的失败:误报会导致工具被禁用;漏报则会被仍然与此门禁并存的人工审查所捕获。本仓库中的每一个设计决策都倾向于这种权衡,这就是为什么下面的规则集很短,并且每个规则的 YAML 都像记录它检测什么一样,谨慎地记录了它刻意*不*检测什么。
## 规则
目前有四条规则。每条规则在触发前都需要多个确证信号——没有哪条规则会因为单一的微弱信号而阻断。
| 规则 | 检测内容 | 阻断层级 |
|---|---|
| **MG-001** — 硬编码的生产环境机密 | AWS 访问密钥、GCP/Firebase API 密钥、Stripe 正式环境机密密钥、GitHub PAT(classic + fine-grained)、Slack token、带有实际密钥主体的 PEM 私钥块 —— 位于 DEX 字符串池、`resources.arsc`、`AndroidManifest.xml` 和 `assets/**` 中。Provider-prefix 模式改编自 gitleaks/trufflehog 的公开规则集。 | 阻断 |
| **MG-002** — 明文/接受所有传输 | `android:usesCleartextTraffic="true"`(显式设置,或通过 `targetSdkVersion < 28` 隐式生效),以及 `network_security_config.xml` ``/`` 块允许明文 —— 域名范围的匹配仅针对您配置的第一方域名白名单触发,绝不进行推断。 | 阻断 |
| **MG-003** — 明文敏感存储(备份暴露) | `android:allowBackup="true"`(显式设置,或通过 `targetSdkVersion < 31` 隐式生效),且没有真正限制内容的 `fullBackupContent`/`dataExtractionRules` 覆盖,也没有自定义的 `backupAgent`。隐式且被 `targetSdkVersion≥31` 缩小范围的情况属于警告层级,而非阻断 —— 主要的本地提取路径在现代目标版本上已关闭,云/D2D 备份仍然是残余风险。 | 阻断(其中一个信号为警告层级) |
| **MG-010** — 调试/测试构建产物 | 发布候选版本上的 `android:debuggable="true"` 和 `android:testOnly="true"`。不在原始规范的 MG-001–MG-009 目录中 —— 特意从 MG-003 中拆分出来:构建产物的卫生与存储暴露属于不同的威胁模型,且修复负责人也不同。 | 阻断 |
每条规则的 YAML (`rules/*.yaml`) 都记录了其确切的信号逻辑、排除的内容及其原因,以及确立阻断层级状态的语料库证据。该文档才是实际的规范 —— 此表仅是其摘要,而非反过来。
**MG-004**(没有权限守卫的导出组件)已作为提升候选者写入规范,但尚未实现 —— 根据本项目自身的验收门禁(见下文),在进入阻断层级之前,它需要自己的否定测试固件套件。
## 范围限制 —— 明确声明
这些是架构决策,而不是等待通过下一个 PR 来填补的空缺。在假设您期待的某个发现因为 bug 而缺失之前,请先阅读它们。
**无 DEX bytecode 分析。** DEX 解析器读取字符串池 (header → `string_ids` → MUTF-8 表) 以及足够的类/方法/字段结构,以将字符串归因于其声明类型。它不进行反编译,也不遍历方法体。这是一条硬性架构界线,而不是缺少的功能 —— 参见 `CLAUDE.md`:
*"您不需要将反编译为 Smali... 不要让它演变成那样。"* 具体而言,这意味着 MobileGate 不能也不会检测:
- 一个接受所有连接的 `TrustManager`/`HostnameVerifier`(空的 `checkServerTrusted` 主体)。一个*引用* `X509TrustManager` 的类并不能证明这是一个接受所有连接的实现 —— 合法的实现很常见 —— 而且仅靠字符串池无法将它们区分开来。这在规范中被列为 MG-002 的第三个信号,但并未构建。
- 位于 `openFileOutput`/`getSharedPreferences` 调用点的 `MODE_WORLD_READABLE`/`MODE_WORLD_WRITEABLE` —— 这些是调用点处的整数常量,如果不阅读传入它们的方法体,就无法看到。
- 某个存储 API 的加密是否在代码中被显式禁用。
**无 `lib/*.so` (native/NDK) 解析。** 开发人员确实会将机密嵌入到原生代码中,错误地认为这样更难提取 —— 其实不然,`strings libfoo.so` 同样能轻松找到这些字节 —— 但 `.so` 是 ELF 格式,而不是 Android 的 chunk 格式,需要自己的读取器(ELF 节解析,然后是相同的模式匹配信号)。这是一个尚未构建的全新解析器接口,而不是反编译器的扩展。
**无内容推断阻断。** MG-003 基于*配置* (`allowBackup`) 进行阻断,从不基于静态地猜测某个特定的写入操作看起来像是包含 token。这种推断极易产生误报,根据规范,明确将其排除在阻断层级之外。
**仅限 Android APK。** 没有 iOS。不是“在这个迭代中稍后实现” —— 它需要独立创建一个版本,在这个代码库中没有为它预先构建任何共享抽象。
**检测或门禁决策路径中无 LLM。** 上述每个信号都是对已解析数据的确定性正则表达式/结构匹配。唯一允许使用 LLM 的地方(目前未使用)是为确定性规则已确认的发现生成人类可读的修复文本 —— 绝不用于检测,绝不用于门禁决策本身。
如果某一类 bug 需要通过 bytecode 分析才能可靠捕获,诚实的答案是 MobileGate 尚不能捕获它,而不是它“可能没问题”。重新审视其中的任何一项都需要做出深思熟虑的决定,即全面增加 bytecode 分析能力,而不是为单条规则开特例。
## 基准模式:采用时避免满屏飘红
企业拥有现存的技术债务。如果一个门禁在第一天对每一个现存的发现都进行阻断,它会被禁用,而不是被修复。基准模式会快照当前的发现,并仅针对*回归*进行阻断 —— 即快照中不存在的新阻断性发现 —— 同时让现存的债务静默通过……但它实际上不是静默的:每一个被豁免的发现仍然会显示在报告中,只是不计入门禁决策。
```
# 对 legacy app 进行 adopt:对已有内容进行 snapshot。
mobilegate baseline -write app-release.apk
# 已写入 baseline:捕获到 2 个 blocking finding,并保存至 .mobilegate-baseline.yml
# 此后,将针对其进行 scan。
mobilegate -baseline .mobilegate-baseline.yml app-release.apk
```
**证明该机制的演示,而非仅仅描述:** VLC 的语料库扫描(见下文)有两个现存的发现 —— 一个嵌入的 RSA 私钥和一个显式的明文流量标志。从该状态写入基准后:
```
$ mobilegate -baseline vlc-baseline.yml app.apk
RELEASE STATUS: PASS
score: 100/100
No blocking findings.
2 pre-existing finding(s) grandfathered by baseline (not blocking):
MG-001 — Hardcoded production secret (1 finding)
MG-002 — Cleartext / accept-all transport (1 finding)
```
然后,在同一个 APK 中植入了一个新的机密(一个位于新 asset 文件中的 Stripe 密钥形状的字符串),并针对*相同的、未修改的*基准进行了扫描:
```
$ mobilegate -baseline vlc-baseline.yml app-with-planted-secret.apk
RELEASE STATUS: BLOCKED
score: 39/100
Failed controls:
MG-001 — Hardcoded production secret (1 finding)
[assets/planted_secret.txt] sk_liv********************STUVWX
2 pre-existing finding(s) grandfathered by baseline (not blocking):
MG-001 — Hardcoded production secret (1 finding)
MG-002 — Cleartext / accept-all transport (1 finding)
```
新的发现被阻断了。而那两个现存的发现没有。这就是整个机制。
基准文件 (`.mobilegate-baseline.yml`) 是纯文本、已排序、可 diff 的 YAML,旨在像任何其他配置更改一样被提交和审查 —— 而不是一个不透明的缓存:
```
scanner_version: 0.1.0
rule_version: 2026.07.1
findings:
- finding_hash: sha256:42d42e9a...
rule_id: MG-001
title: Hardcoded Private key block header
source: classes4.dex
excerpt: -----B********...d6gaWp
```
**身份标识是 `finding_hash` —— 规则 ID + 文件路径 + 规范化的匹配值,刻意排除了行号。** 一个在其他方面未更改的文件中移动了行号的发现(重构、编译器/混淆设置更改)不能被记录为“新”。这已经过直接测试:在同一个文件中,从一行移动到另一行的机密会产生相同的哈希值。
**渐进收紧,而非大赦:** `baseline -write` 总是用当前发现的完整快照替换文件 —— 它从不与之前存在的内容合并。被修复的发现不会出现在下一次扫描中,因此也不会出现在下一次写入中。一旦它确实消失了,它就不会在过时的条目下悄悄保留豁免状态。
**安全失败。** 缺少基准文件会回退到严格模式并附带解释性消息(首次使用时会预期出现此情况)。损坏、无法读取或 schema 不匹配的基准文件*也会*回退到严格模式 —— 会显眼地显示在 stderr 上,并将所有现有发现视为新发现 —— 绝不静默通过。同样的原则也适用于 `.mobilegate.yml` 本身:缺少配置是合法的默认情况;无效的配置会回退到安全默认值(严格模式、无抑制)并发出响亮的警告,而不是崩溃,也不是静默通过。
## 策略:`.mobilegate.yml`
策略存在于您的团队审查并提交的文件中,而不是存在于碰巧传递了某些 flag 的 CI 调用者那里。
```
policy:
mode: baseline # or "strict" (default)
baseline_file: .mobilegate-baseline.yml
first_party_domains:
- example.com # MG-002's domain-config allowlist
ignore_rules:
- id: "MG-002"
reason: "Required — suppression without a reason is a config load error, not a warning."
paths: ["AndroidManifest.xml"] # omit to suppress the rule everywhere
```
CLI flag (`-mode`, `-baseline`) 仅在显式传递时才会覆盖已提交的文件,因此 CI 可以在不修改已签入内容的情况下强制某次运行使用严格模式。被抑制的发现绝不会静默丢弃 —— 它们会以 suppressed-with-reason 显示在每种输出格式中,这与基准模式应用于被豁免债务的可见性原则相同。
## 语料库结果
十二个真实的、开源的 F-Droid APK —— 不是合成的测试固件,也不是客户项目(具体是哪些应用以及如何自行获取它们,请参见 `testdata/real/README.md`)。
**严格模式:8 个 BLOCKED / 4 个 PASS。**
| 结果 | 应用 |
|---|---|
| BLOCKED | Nextcloud, AntennaPod, Conversations, Fennec (MG-002 — 宽松的 `network_security_config` base-config);Simple Flashlight, NewPipe (MG-003 — `allowBackup`);Material Files(两者皆有);VLC (MG-001 + MG-002) |
| PASS | Tusky, KeePassDX, Termux, Dolphin |
**基准模式,从相同状态写入基准后:12/12 PASS** —— 每个现存的发现都显示为被豁免,没有丢弃,也没有被静默隐藏。
**该语料库上一个真正的凭据发现,而不是配置默认值:** VLC 的 `classes4.dex` 包含一个完整的 RSA 私钥及其自签名证书 (`CN=example.com`) —— MG-001 的 `private-key-header` 模式特别要求在 PEM 标记之后必须紧跟实际的 base64 密钥主体,以避免在每个 TLS 库附带的纯边界常量上触发(在存在主体长度要求之前,这条规则在早期语料库运行中两次触发了该误报 —— Nextcloud、KeePassDX)。`CN=example.com` 强烈暗示这是一个捆绑的示例/测试证书,而不是实际的生产密钥,但 MobileGate 报告了这一配置事实后就停止了 —— 它不试图猜测意图,这完全属于审查该发现的人工人员应当做出的判断。
完整的性能数据(P95 扫描时间、整个语料库的峰值 RSS,随着规则的增加进行跟踪)位于 `PERFORMANCE.md` 中 —— 激活所有四条规则时观察到的最坏情况完全在规范要求的 90 秒 / 1 GB 目标范围之内。
## 验证:解析器 oracle
五个解析器 oracle(`tools/oracle/*_test.go`,`make oracle`,受限于 build tag,因此它们永远不会编译进发布的二进制文件或在 CI 中运行)在真实的 APK 上将 MobileGate 自身的解析器与独立、从头编写的 Android 工具 —— `aapt2`、`apkanalyzer`、`dexdump` —— 进行了交叉校验:
| Oracle | 交叉校验对象 | 验证内容 |
|---|---|---|
| Manifest | `apkanalyzer manifest print` (回退至 `aapt2 dump badging`) | package name, `usesCleartextTraffic`, 每个组件的 `exported`/`permission` |
| DEX 字符串计数 | `dexdump -f` | `string_ids_size` 完全匹配,适用于单 DEX 和多 DEX APK |
| `resources.arsc` 字符串池 | `aapt2 dump strings` | 完整多重集匹配(与顺序无关 —— 原因见 oracle 本身的文档注释) |
| `network_security_config.xml` | `aapt2 dump xmltree` | 域名和 `includeSubdomains`,这是专门检查 CDATA 文本的唯一 oracle |
| `AndroidManifest.xml` 字符串池 | `aapt2 dump xmlstrings` | 相同的多重集检查,外加针对真实输入测试 UTF-16池编码路径的唯一 oracle |
每个 oracle 都经过了突变测试,而不仅仅是编写后就盲目信任:在 `tools/oracle/README.md` 中,每个 oracle 自己的文档注释都记录了临时引入的特定 bug、oracle 捕获它时生成的确切 diff,以及在提交前确认该 bug 已被撤销。
并非每个解析器都有专门的 oracle。`pkg/parser/backuprules` (`fullBackupContent`/`dataExtractionRules` XML) 是通过合成的二进制 XML 单元测试以及在开发过程中手动完成的、针对 `aapt2 dump xmltree` 输出的真实语料库交叉校验来验证的,而不是该套件中的自动化 oracle 测试 —— 在此特别说明,而不是让上面的表格看起来已经涵盖了它。
## 快速开始
**构建:**
```
git clone
cd mobilegate
go build -o mobilegate ./cmd/mobilegate
```
需要 Go 1.26+。没有其他运行时依赖 —— 构建的二进制文件是静态且自包含的。
**扫描一个 APK:**
```
./mobilegate app-release.apk # human-readable gate report
./mobilegate -json app-release.apk # machine-readable output contract
./mobilegate -markdown app-release.apk # GitHub/GitLab PR-comment Markdown
```
退出代码在 `BLOCKED` 时为 `1`,在 `PASS` 时为 `0` —— 旨在直接导致 CI 步骤失败。
**在现有应用上采用(基准模式):**
```
./mobilegate baseline -write app-release.apk
git add .mobilegate-baseline.yml
git commit -m "Adopt MobileGate: baseline existing findings"
```
然后在 `.mobilegate.yml` 中设置 `policy.mode: baseline`(或在命令行上传递 `-baseline .mobilegate-baseline.yml`),以便未来的扫描仅针对回归进行阻断。
**接入 CI** (GitHub Actions 示例):
```
- name: MobileGate release gate
run: |
./mobilegate app-release.apk
```
仅凭命令的退出代码就足以在 `BLOCKED` 时使步骤失败。如果还要发布 PR 评论,请捕获 Markdown 输出,并将其提供给 CI 平台提供的任何评论发布 action:
```
- name: MobileGate release gate
run: ./mobilegate -markdown app-release.apk > mobilegate-comment.md
# then hand mobilegate-comment.md to your PR-comment action of choice
```
## 开发
```
make test # unit + fixture suites (go test ./...)
make fetch-testdata # pulls the two pinned dev-verification APKs
make oracle # cross-checks parsers against aapt2/apkanalyzer/dexdump (requires Android SDK cmdline-tools)
```
有关该项目的硬性约束(仅限 Go、不通过 shell 调用 JVM 工具、仅限合成固件等),请参见 `CLAUDE.md`;有关完整的原始规范,请参见 `mobile-security-release-gate-build-prompt-v2.1.md`。
## License
Apache License 2.0 —— 见 `LICENSE`。之所以选择它而不是 MIT,是因为其明确的专利授权:该工具的目标采用者(受监管的组织 —— 银行、医疗保健、保险、金融科技)在依赖项集成到 CI pipeline 之前会进行法律审查,而 Apache-2.0 的专利条款正是此类审查通常要求的。两个直接依赖项(`github.com/shogo82148/androidbinary`, `github.com/goccy/go-yaml`)均为 MIT 许可,且相互兼容。
MG-001 的凭据模式*形状*(非代码)改编自 gitleaks 和 trufflehog 的公开规则集,并与每个 provider 自己发布的 token 格式文档进行了交叉校验 —— 完整出处请参见 `rules/MG-001-hardcoded-secret.yaml` 的头部。
## 贡献
见 `CONTRIBUTING.md`。
标签:DevSecOps, EVTX分析, Go, Ruby工具, StruQ, 上游代理, 云安全监控, 安全, 日志审计, 移动开发, 超时处理, 静态分析