Mob ships three commands for tracking known vulnerabilities across every surface a Mob app actually compiles into the binary:
| Command | When to use it |
|---|---|
mix mob.security_scan | One-off scan. Pretty terminal output for the human at the keyboard. |
mix mob.security_scan.log | Scheduled run (cron / GitHub Actions). Writes a current snapshot, prepends a delta entry to a changelog, and persists state across runs. |
mix mob.release --security-gate | Release-time gate. Runs the scan, aborts the build on any critical/high/medium finding. |
All three sit on top of the same scan engine — pick the wrapper that matches your trigger.
What gets scanned
Seven layers, each running independently and aggregated into one
report. A missing external scanner is a soft warning (the layer
reports tool missing), not a failure.
| Layer | Tool(s) | Surface area |
|---|---|---|
hex_deps | mix_audit + osv-scanner | Hex deps in mix.lock. Two sources because they miss different things — the Erlef CNA feed (osv-scanner) tends to surface CVE-numbered advisories Mirego's curated database (mix_audit) hasn't ingested yet. |
gradle_deps | osv-scanner | Android Gradle deps. Best results when you've turned on Gradle dependency locking — see "Enabling Gradle dependency locking" below. |
swift_deps | osv-scanner | iOS Swift Package Manager (Package.resolved) and CocoaPods (Podfile.lock). |
bundled_runtime | manifest + binary fingerprint + upstream advisories | OpenSSL, ERTS, Elixir, exqlite, SQLite baked into Mob's pre-built OTP tarball. Generic dep scanners can't see these because they're in static archives, not lockfiles. Known CVEs in the bundled OTP applications and OpenSSL, OpenSSL branch end-of-life, and CVEs on sqlite.org/cves.html fixed after the project's exqlite SQLite (:medium, rows SQLite disputes ignored) are findings. |
c_source | semgrep + flawfinder | Mob's NIF C/Objective-C plus the exqlite NIF wrapper. Excludes the SQLite amalgamation (huge, battle-tested, would generate thousands of low-value findings). |
kotlin_source | detekt | Kotlin/Java under android/app/src/main/. Set MOB_DETEKT_CONFIG=path to use a security-focused rule config. |
swift_source | swiftlint | Swift under ios/. Mob's iOS bridge is mostly Objective-C, which is covered by c_source instead. |
The bundled_runtime layer is the one that makes Mob's scan
unusual. It opens libcrypto.a from the cached OTP tarball, scans
the .rodata section for the OpenSSL version banner, and emits a
:high finding if the binary disagrees with what
priv/security/bundled_versions.exs
claims shipped. That manifest is the source of truth for what's
inside the static archives Mob distributes; the fingerprinter is
the receipt that proves the manifest is honest.
For the active runtime (the tarball hash this mob_dev deploys) the
layer also matches the fingerprinted versions against the
erlang/otp GitHub security advisories
(per application: ssl, public_key, erts, …), OpenSSL's
per-branch vulnerability page,
and endoflife.date. Each affected
advisory is a finding with the upstream severity and the fixed
version; an OpenSSL branch past end of life is :high. Fetched data
is cached in advisories/ under $MOB_CACHE_DIR (default
~/.mob/cache) for 24 hours, and a cached
copy is used (flagged stale) when the network is unavailable.
One-time setup
brew install osv-scanner semgrep flawfinder detekt swiftlint
mix_audit is a Hex dependency of mob_dev, no extra install.
The OpenSSL/SQLite/OTP fingerprinting is pure Elixir — no external
strings(1) or similar required.
Each layer soft-degrades when its scanner is missing, so install
incrementally as you want fuller coverage. osv-scanner is the
highest-value install (it drives three layers).
mix mob.security_scan
The interactive entry point. Run it whenever you want to know "what does this project look like right now?".
mix mob.security_scan # full scan, terminal output
mix mob.security_scan --json # machine-readable to stdout
mix mob.security_scan --skip kotlin_source,c_source
mix mob.security_scan --strict # exit 1 on any critical/high/medium
mix mob.security_scan --write-report SECURITY_SCAN.md
Output is severity-coloured, sorted critical → unknown, with
fixed_in versions when known and clickable advisory URLs.
mix mob.security_scan.log
The scheduled-run wrapper. Designed for cron, GitHub Actions, or any other recurring trigger. Each run writes three files at the project root:
| File | Role |
|---|---|
SECURITY_SCAN.md | Current-state snapshot, overwritten each run. |
SECURITY_HISTORY.md | Append-only changelog, newest entry on top. Each entry has New since last scan, Resolved since last scan, and Still present from last scan sections. Persisting findings carry a _(first seen N days ago)_ patch-lag suffix. |
.security_scan/state.json | JSON sidecar that records the last-known finding set + per-finding first_seen_at timestamps. |
Commit all three. The state file is what makes the changelog meaningful across machines and CI runs — without it, every run reports every finding as "new" and the timeline loses signal.
A typical history entry
## 2026-05-07T13:59:24Z
**Project:** `/Users/me/myapp`
**Total findings:** 2 (0 critical, 2 high, 0 medium, 0 low, 0 unknown)
### New since last scan (1)
- **HIGH** `mob/otp-tarball@ios_sim` `[MOB-DRIFT-ios_sim-elixir]` —
Bundled-versions drift: Elixir manifest=1.19.5 binary=1.20.0-rc.4
### Resolved since last scan (1) ✓
- **HIGH** `phoenix@1.8.5` `[EEF-CVE-2026-32689]` —
Long-poll NDJSON body splitting causes unbounded memory allocation
### Still present from last scan (1)
- **CRITICAL** `openssl@3.4.0` ... _(first seen 22 days ago)_Cron entry
# daily at 06:00 local
0 6 * * * cd /path/to/project && mix mob.security_scan.log >> /tmp/security_scan.log 2>&1
GitHub Actions workflow
Opens a PR each week with the three files updated:
name: security-scan
on:
schedule: [{cron: "0 6 * * 1"}]
workflow_dispatch:
jobs:
scan:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with: {elixir-version: "1.19", otp-version: "28"}
- run: brew install osv-scanner semgrep flawfinder detekt swiftlint
- run: mix deps.get
- run: mix mob.security_scan.log
- uses: peter-evans/create-pull-request@v6
with:
title: "security: weekly scan update"
branch: security-scan-update
add-paths: |
SECURITY_SCAN.md
SECURITY_HISTORY.md
.security_scan/state.jsonEach scan becomes a small, reviewable PR that explicitly tells you "this week we fixed N, found M new, still owe these K."
mix mob.release --security-gate
The release gate. Wraps mix mob.security_scan --strict around
your release build so a vulnerable artifact never reaches signing.
mix mob.release --android --security-gate
mix mob.release --ios --security-gate
If the scan surfaces any critical/high/medium finding, the release
aborts with exit code 1 — nothing is built, nothing is signed.
Low/unknown findings are non-blocking (you can review them in
SECURITY_SCAN.md).
The same flag works alongside the existing release flags:
mix mob.release --ios --slim --security-gate
The success printouts of mix mob.release mention --security-gate
as a tip whenever the gate wasn't used, so the option stays visible.
Updating after rebuilding the OTP tarballs
When Mob's pre-built OTP runtime is rebuilt
(build_release.md),
priv/security/bundled_versions.exs
must be updated to declare the new versions baked into each
tarball. The bundled_runtime layer fingerprints the cached
binaries on disk and emits a :high "manifest drift" finding if
the manifest disagrees with what's actually shipping — that's the
exact failure mode the manifest exists to catch. Update it in the
same PR as the OTP hash bump.
Enabling Gradle dependency locking
By default, Gradle doesn't lock transitive dependencies, which
means osv-scanner only sees the deps you've declared in
build.gradle. To get full transitive coverage, opt into Gradle's
dependency locking:
// android/build.gradle
allprojects {
configurations.all {
resolutionStrategy.activateDependencyLocking()
}
}
// android/app/build.gradle
dependencyLocking {
lockAllConfigurations()
}Then run:
cd android && ./gradlew :app:dependencies --write-locks
gradle.lockfile will appear under android/app/. Commit it.
Subsequent scans will pick up every transitive dep and surface
its CVEs.
Tradeoffs and limitations
OpenSSL/OTP CVEs come from upstream pages, not OSV. OSV.dev doesn't index native libraries as packages, and its Erlang/OTP records carry git ranges only, so the
bundled_runtimelayer reads the erlang/otp GitHub advisories and OpenSSL's per-branch HTML page directly. If OpenSSL redesigns that page the layer reports it as unrecognised (never as "no CVEs") and falls back to the cached copy. With no network and no cache it warns and points at the pages to check by hand.SQLite CVEs come from
sqlite.org/cves.html. The layer matches the SQLite version indeps/exqlite/c_src/sqlite3.cagainst that table's "Fix" column; it has no severity, so findings are:medium. Like the OpenSSL page, a redesign is reported as unrecognised and the cached copy is used.No live Elixir CVE feed. The layer reports the Elixir version it found and points at the Elixir security advisories so you can verify manually. The manifest-drift detection runs every scan — that catches your manifest lying about what shipped.
xcodebuild analyzeis not in the Swift layer. The Clang Static Analyzer is the gold standard for Objective-C and Swift but requires a buildable Xcode project (signing, provisioning, the works).swiftlintis the pragmatic substitute that doesn't need a build. If you want clang-analyze in CI, run it as a separate step.SQLite amalgamation is excluded from C-source scanning. It's ~9MB of well-tested code; running general C rules over it would produce thousands of low-value findings. Use the
bundled_runtimelayer, which matches its version againstsqlite.org/cves.html, instead.