mobilegate

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: Apache-2.0

README

MobileGate

An Android APK release gate for CI/CD. It answers one question — PASS or BLOCKED — and lists the specific controls that failed. It is not a findings report, and it does not try to be comprehensive.

Single static Go binary, no runtime dependencies, no JVM. Pure static analysis: no device, no emulator, no network access required to run a scan.

$ 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 <application> ...
      remediation:   Set android:allowBackup="false" on <application> unless ...

Why this isn't MobSF

MobSF says "here are 150 findings." MobileGate says "your release is blocked because these controls failed." That's the entire product difference, and it's deliberate, not a limitation of a smaller tool.

The promise is precision, not coverage. A clean APK must produce zero blocking findings — that property is the product. A blocking rule that fires on a clean app is a worse failure than one that misses a real issue: the false positive gets the tool disabled; the miss is caught by the human review that still exists alongside this gate. Every design decision in this repo resolves in favor of that tradeoff, which is why the rule set below is short and each rule's YAML documents what it deliberately does not detect as carefully as what it does.

The rules

Four rules exist today. Each rule requires multiple corroborating signals before it fires — no rule blocks on a single weak signal.

Rule Detects Blocking tier
MG-001 — Hardcoded production secret AWS access keys, GCP/Firebase API keys, Stripe live secret keys, GitHub PATs (classic + fine-grained), Slack tokens, PEM private-key blocks with an actual key body — in the DEX string pool, resources.arsc, AndroidManifest.xml, and assets/**. Provider-prefix patterns adapted from gitleaks/trufflehog's public rule sets. Blocking
MG-002 — Cleartext / accept-all transport android:usesCleartextTraffic="true" (explicit, or implicit via targetSdkVersion < 28), and network_security_config.xml <base-config>/<domain-config> blocks permitting cleartext — domain-scoped matches only fire against a first-party domain allowlist you configure, never inferred. Blocking
MG-003 — Plaintext sensitive storage (backup exposure) android:allowBackup="true" (explicit, or implicit via targetSdkVersion < 31) with no fullBackupContent/dataExtractionRules override that actually restricts something, and no custom backupAgent. Implicit-and-narrowed-by-targetSdkVersion≥31 is warning-tier, not blocking — the primary local-extraction path is closed on modern targets, cloud/D2D backup remain a residual risk. Blocking (one signal is warning-tier)
MG-010 — Debug/test build artifact android:debuggable="true" and android:testOnly="true" on the release candidate. Not in the original spec's MG-001–MG-009 catalog — split out from MG-003 deliberately: build-artifact hygiene is a different threat model and a different remediation owner than storage exposure. Blocking

Each rule's YAML (rules/*.yaml) documents its exact signal logic, what it excludes and why, and the corpus evidence behind blocking-tier status. That documentation is the actual spec — this table is a summary of it, not the other way around.

MG-004 (exported components without a permission guard) is written into the spec as a promotion candidate but not implemented — it needs its own negative-fixture suite before entering the blocking tier, per this project's own acceptance gate (see below).

Scope limits — stated plainly

These are architectural decisions, not gaps waiting to be filled by the next PR. Read them before assuming a finding you expected is missing because of a bug.

No DEX bytecode analysis. The DEX parser reads the string pool (header → string_ids → MUTF-8 table) and enough class/method/field structure to attribute a string to its declaring type. It does not decompile, and it does not walk method bodies. This is a hard architectural line, not a missing feature — see CLAUDE.md: "You do NOT need decompilation to Smali... do not let it grow into one." Concretely, this means MobileGate cannot and does not detect:

  • An accept-all TrustManager/HostnameVerifier (an empty checkServerTrusted body). A class referencing X509TrustManager is not evidence of an accept-all implementation — legitimate implementations are common — and there is no string-pool-only way to tell them apart. This is spec'd as MG-002's third signal and is not built.
  • MODE_WORLD_READABLE/MODE_WORLD_WRITEABLE at openFileOutput/ getSharedPreferences call sites — these are integer constants at a call site, invisible without reading the method body they're passed into.
  • Whether a storage API's encryption was explicitly disabled in code.

No lib/*.so (native/NDK) parsing. Developers do embed secrets in native code on the mistaken belief it's harder to extract — it isn't, strings libfoo.so finds the same bytes just as easily — but .so is ELF, not an Android chunk format, and needs its own reader (ELF section parsing, then the same pattern-matching signals). That's new parser surface that hasn't been built, not a decompiler extension.

No content-inference blocking. MG-003 blocks on storage configuration (allowBackup), never on statically guessing that a particular write looks like it contains a token. That inference is false-positive-prone and is explicitly out of scope for the blocking tier per the spec.

Android APK only. No iOS. Not "later in this sprint" — a separate version, with no shared abstractions pre-built for it in this codebase.

No LLM in the detection or gate-decision path. Every signal above is deterministic regex/structural matching over parsed data. The only place an LLM is even permitted (not currently used) is generating the human-readable remediation text for a finding a deterministic rule has already confirmed — never detection, never the gate decision itself.

If a class of bug needs bytecode analysis to catch reliably, the honest answer is that MobileGate doesn't catch it yet, not that it's "probably fine." Revisiting any of these requires a deliberate decision to add bytecode-analysis capability generally, not a one-off carve-out for a single rule.

Baseline mode: adopt without a wall of red

Enterprises have existing debt. A gate that blocks on every pre-existing finding on day one gets disabled, not fixed. Baseline mode snapshots current findings and blocks only on regressions — new blocking findings not present in that snapshot — while pre-existing debt passes silently... except it isn't silent: every grandfathered finding is still shown in the report, just not counted toward the gate decision.

# Adopt on a legacy app: snapshot what's already there.
mobilegate baseline -write app-release.apk
# wrote baseline: 2 blocking finding(s) captured to .mobilegate-baseline.yml

# From then on, scan against it.
mobilegate -baseline .mobilegate-baseline.yml app-release.apk

The demo that proves the mechanism, not just describes it: VLC's corpus scan (see below) has two pre-existing findings — an embedded RSA private key and an explicit cleartext-traffic flag. After writing a baseline from that state:

$ 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)

Then a fresh secret was planted in the same APK (a Stripe-key-shaped string in a new asset file) and scanned against the same, unmodified baseline:

$ 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)

The new finding blocked. The two pre-existing ones didn't. That's the whole mechanism.

The baseline file (.mobilegate-baseline.yml) is plain, sorted, diffable YAML meant to be committed and reviewed like any other config change — not an opaque cache:

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

Identity is finding_hash — rule ID + file path + normalized match value, deliberately excluding line number. A finding that moves lines in an otherwise-unchanged file (a refactor, a compiler/obfuscation setting change) must not register as "new." That's tested directly: a secret moved from one line to another in the same file produces an identical hash.

Ratchet, not amnesty: baseline -write always replaces the file with a full snapshot of what's currently found — it never merges with what was there before. A finding that gets fixed simply isn't in the next scan, so it isn't in the next write. It cannot silently stay grandfathered under a stale entry once it's actually gone.

Fails closed. A missing baseline file falls back to strict mode with an explanatory message (expected on first use). A baseline file that's corrupt, unreadable, or schema-mismatched also falls back to strict — loudly, on stderr, with every existing finding treated as new — never a silent pass. Same discipline applies to .mobilegate.yml itself: a missing config is a legitimate default; an invalid one falls back to safe defaults (strict mode, no suppressions) with a loud warning, not a crash and not a silent pass.

Policy: .mobilegate.yml

Policy lives in a file your team reviews and commits, not in whoever's CI invocation happens to pass which flags.

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 flags (-mode, -baseline) override the committed file only when explicitly passed, so CI can force strict mode for one run without touching what's checked in. Suppressed findings are never dropped silently — they show up in every output format as suppressed-with-reason, the same visibility principle baseline mode applies to grandfathered debt.

Corpus results

Twelve real, open-source F-Droid APKs — not synthetic fixtures, not a client engagement (see testdata/real/README.md for exactly which apps and how to fetch them yourself).

Strict mode: 8 BLOCKED / 4 PASS.

Outcome Apps
BLOCKED Nextcloud, AntennaPod, Conversations, Fennec (MG-002 — permissive network_security_config base-config); Simple Flashlight, NewPipe (MG-003 — allowBackup); Material Files (both); VLC (MG-001 + MG-002)
PASS Tusky, KeePassDX, Termux, Dolphin

Baseline mode, after writing a baseline from that same state: 12/12 PASS — every pre-existing finding shown as grandfathered, none dropped, none silently hidden.

The one finding on this corpus that's a genuine credential, not a config default: VLC's classes4.dex contains a complete RSA private key and its self-signed certificate (CN=example.com) — MG-001's private-key-header pattern requires an actual base64 key body after the PEM marker specifically to avoid firing on bare boundary constants every TLS library ships (this rule hit that false positive twice in early corpus runs — Nextcloud, KeePassDX — before the body-length requirement existed). The CN=example.com strongly suggests this is a bundled example/test certificate rather than a live production key, but MobileGate reports the configuration fact and stops there — it does not attempt to guess intent, that's exactly the kind of judgment call that belongs to the human reviewing the finding.

Full performance numbers (P95 scan time, peak RSS across the corpus, tracked as rules are added) are in PERFORMANCE.md — the worst observed case with all four rules active is well inside the spec's 90-second / 1 GB targets.

Verification: parser oracles

Five parser oracles (tools/oracle/*_test.go, make oracle, gated behind a build tag so they never compile into the shipped binary or run in CI) cross-check MobileGate's own parsers against independent, from-scratch Android tooling — aapt2, apkanalyzer, dexdump — on real APKs:

Oracle Cross-checks against What it verifies
Manifest apkanalyzer manifest print (falls back to aapt2 dump badging) package name, usesCleartextTraffic, every component's exported/permission
DEX string count dexdump -f string_ids_size matches exactly, on single- and multi-dex APKs
resources.arsc string pool aapt2 dump strings full multiset match (order-independent — see the oracle's own doc comment for why)
network_security_config.xml aapt2 dump xmltree domain names and includeSubdomains, the one oracle that checks CDATA text specifically
AndroidManifest.xml string pool aapt2 dump xmlstrings same multiset check, plus the only oracle that exercises the UTF-16 pool-encoding path against real input

Every oracle is mutation-tested, not just written and trusted: each one's own doc comment in tools/oracle/README.md records the specific bug that was temporarily introduced, the exact diff the oracle produced when it caught it, and confirmation the bug was reverted before committing.

Not every parser has a dedicated oracle. pkg/parser/backuprules (fullBackupContent/dataExtractionRules XML) is verified by synthetic binary-XML unit tests plus real-corpus cross-checking against aapt2 dump xmltree output done manually during development, not an automated oracle test in this suite — noted here rather than left to look covered by the table above.

Quickstart

Build:

git clone <this-repo>
cd mobilegate
go build -o mobilegate ./cmd/mobilegate

Requires Go 1.26+. No other runtime dependency — the built binary is static and self-contained.

Scan an 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

Exit code is 1 on BLOCKED, 0 on PASS — designed to fail a CI step directly.

Adopt on an existing app (baseline mode):

./mobilegate baseline -write app-release.apk
git add .mobilegate-baseline.yml
git commit -m "Adopt MobileGate: baseline existing findings"

Then set policy.mode: baseline in .mobilegate.yml (or pass -baseline .mobilegate-baseline.yml on the command line) so future scans only block on regressions.

GitHub Action

prasadnadkarni/mobilegate@v0.1.0 is a composite action: it downloads the pinned release binary (checksum-verified, no build step, no Go toolchain needed on your runner), runs it against an APK, fails the workflow on BLOCKED, and posts or updates a PR comment with the Markdown report.

The realistic case — the APK is a build artifact from an earlier step in the same job, not a file committed to the repo:

name: MobileGate release gate

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write   # required to post/update the PR comment — see "Permissions" below

jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "17"

      - name: Build release APK
        run: ./gradlew assembleRelease

      - uses: prasadnadkarni/mobilegate@v0.1.0
        with:
          apk-path: app/build/outputs/apk/release/app-release-unsigned.apk
          # config-path: .mobilegate.yml        # optional, this is already the default
          # baseline-path: .mobilegate-baseline.yml  # optional — omit to let policy.mode in .mobilegate.yml decide

If the APK is built in a different job (a separate build job feeding a separate gate job), it has to cross the job boundary explicitly — each job is a clean filesystem:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew assembleRelease
      - uses: actions/upload-artifact@v4
        with:
          name: release-apk
          path: app/build/outputs/apk/release/app-release-unsigned.apk

  gate:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: release-apk
      - uses: prasadnadkarni/mobilegate@v0.1.0
        with:
          apk-path: app-release-unsigned.apk
Inputs
Input Required Default Notes
apk-path yes Fails the action immediately (before downloading anything) if the file doesn't exist.
config-path no (unset — MobileGate's own default, .mobilegate.yml)
baseline-path no (unset — mode/path come from .mobilegate.yml's policy.mode) Setting this alone also switches to baseline mode, same shorthand as the CLI's -baseline flag.
version no latest Pin an exact tag (v0.1.0) for a reproducible pipeline — latest can change under you between runs.
comment-on-pr no true No-op (not an error) on any event that isn't a pull request.
comment-marker no default Change only if one workflow scans multiple APKs and needs a separate comment per APK.
fail-on-comment-error no true See "Permissions" below.
github-token no ${{ github.token }}
Outputs

gate-decision (pass/blocked) and score, for a downstream step that wants to branch on the result beyond the action's own exit code.

Exit code and the PR comment: order of operations

A BLOCKED result must still get its PR comment posted — that's the whole point, that's when the comment matters most. The action runs the scan, posts/updates the comment regardless of the result, and only then exits with the scan's real exit code as its last step. A workflow step failure from BLOCKED always shows up after the comment step has had its chance to run, never instead of it.

Sticky comment, not a stack

Every comment the action posts carries a hidden marker (<!-- mobilegate-report:default --> by default). On each run, the action lists existing PR comments, and if one already carries that marker, it's updated in place — re-running the workflow (a new commit, a manual re-run) never produces a second comment.

Permissions

Posting or updating the PR comment needs pull-requests: write on the token the workflow gives the action. Many orgs now default GITHUB_TOKEN to read-only, in which case you need the explicit block shown in the examples above:

permissions:
  pull-requests: write

What happens if that permission is missing: the action does not silently skip the comment and report success. fail-on-comment-error defaults to true — a permissions failure posting the comment fails the action with a message telling you exactly what to add, even if the scan itself was a clean PASS. Set fail-on-comment-error: false only if you'd rather the scan result alone govern the action's outcome and you're fine with comments silently not appearing when permissions are wrong — that's an explicit opt-out, not the default, on purpose.

Development

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)
goreleaser build --snapshot --clean   # verify cross-compilation locally without tagging a release (requires goreleaser)

.github/workflows/ci.yml runs the same build/vet/test/goreleaser-build checks on every push and PR. .github/workflows/release.yml runs goreleaser release on a pushed v* tag — that's what publishes the binaries action.yml fetches; see .goreleaser.yml.

See CLAUDE.md for the project's hard constraints (Go-only, no shelling out to JVM tooling, synthetic-fixtures-only, etc.) and mobile-security-release-gate-build-prompt-v2.1.md for the full original spec.

License

Apache License 2.0 — see LICENSE. Chosen over MIT for the explicit patent grant: this tool's target adopter (regulated orgs — banking, healthcare, insurance, fintech) runs legal review on dependencies before they ship in a CI pipeline, and Apache-2.0's patent clause is what that review usually asks for. Both direct dependencies (github.com/shogo82148/androidbinary, github.com/goccy/go-yaml) are MIT-licensed and compatible.

MG-001's credential-pattern shapes (not code) are adapted from gitleaks' and trufflehog's public rule sets, cross-checked against each provider's own published token-format documentation — see rules/MG-001-hardcoded-secret.yaml's header for full provenance.

Contributing

See CONTRIBUTING.md.

Directories

Path Synopsis
cmd
mobilegate command
Command mobilegate is the MobileGate CLI entrypoint.
Command mobilegate is the MobileGate CLI entrypoint.
internal
config
Package config loads .mobilegate.yml — MobileGate's own policy config file (spec: "Config file (.mobilegate.yml)").
Package config loads .mobilegate.yml — MobileGate's own policy config file (spec: "Config file (.mobilegate.yml)").
core
Package core holds MobileGate's shared finding model plus the logic that consumes it across every rule: finding_hash, the gate decision, and the score (spec's deliverable structure: "internal/core — shared finding model, hashing (finding_hash), baseline diff, scoring").
Package core holds MobileGate's shared finding model plus the logic that consumes it across every rule: finding_hash, the gate decision, and the score (spec's deliverable structure: "internal/core — shared finding model, hashing (finding_hash), baseline diff, scoring").
engine
Package engine evaluates MobileGate's rules — currently just MG-001 — against parser output.
Package engine evaluates MobileGate's rules — currently just MG-001 — against parser output.
pkg
parser/apk
Package apk opens an APK's zip container and extracts the files the rest of the parser needs: AndroidManifest.xml, resources.arsc, the DEX files, and the assets/ directory.
Package apk opens an APK's zip container and extracts the files the rest of the parser needs: AndroidManifest.xml, resources.arsc, the DEX files, and the assets/ directory.
parser/arsc
Package arsc extracts the global string pool from Android's chunk-based binary resource format — the one place every string-typed resource *value* lives (e.g.
Package arsc extracts the global string pool from Android's chunk-based binary resource format — the one place every string-typed resource *value* lives (e.g.
parser/backuprules
Package backuprules parses android:fullBackupContent (legacy Auto Backup) and android:dataExtractionRules (API 31+) resource files far enough to answer MG-003's structural question: does the referenced file express ANY restriction on backup content at all? It does not evaluate whether that restriction is sufficient for a given app's actual sensitive data — see rules/MG-003-plaintext-storage.yaml for why that judgment is deliberately out of scope.
Package backuprules parses android:fullBackupContent (legacy Auto Backup) and android:dataExtractionRules (API 31+) resource files far enough to answer MG-003's structural question: does the referenced file express ANY restriction on backup content at all? It does not evaluate whether that restriction is sufficient for a given app's actual sensitive data — see rules/MG-003-plaintext-storage.yaml for why that judgment is deliberately out of scope.
parser/dex
Package dex extracts the string pool from a DEX file, tagging each string with best-effort class/method/field attribution derived purely from the fixed-size id tables (string_ids, type_ids, method_ids, field_ids).
Package dex extracts the string pool from a DEX file, tagging each string with best-effort class/method/field attribution derived purely from the fixed-size id tables (string_ids, type_ids, method_ids, field_ids).
parser/manifest
Package manifest extracts the AndroidManifest.xml fields MobileGate's rules need.
Package manifest extracts the AndroidManifest.xml fields MobileGate's rules need.
parser/nsc
Package nsc parses a compiled network_security_config.xml enough to answer MG-002's question: which domains (if any) does this app permit cleartext traffic to, and under what kind of config block.
Package nsc parses a compiled network_security_config.xml enough to answer MG-002's question: which domains (if any) does this app permit cleartext traffic to, and under what kind of config block.
Package rules embeds MobileGate's rule definitions (see mobile-security-release-gate-build-prompt-v2.1.md: "Rules as data, not hardcoded logic in Go").
Package rules embeds MobileGate's rule definitions (see mobile-security-release-gate-build-prompt-v2.1.md: "Rules as data, not hardcoded logic in Go").

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL