Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,12 @@ mix reach # interactive HTML architecture report

`mix mob.security_scan` covers Hex deps, Android Gradle deps, iOS
Swift Package deps, the **bundled OpenSSL/OTP/Elixir/SQLite versions**
(via fingerprint of `~/.mob/cache/otp-*-{hash}/` against
(via fingerprint of `otp-*-{hash}/` in `$MOB_CACHE_DIR`, default
`~/.mob/cache`, against
`priv/security/bundled_versions.exs`, plus erlang/otp and OpenSSL
advisories and OpenSSL branch EOL for the active hash, and the project's
`deps/exqlite` SQLite against `sqlite.org/cves.html`, cached in
`~/.mob/cache/advisories/`), and C/Kotlin/Swift static
`advisories/` under the same dir), and C/Kotlin/Swift static
analysis. See [`README.md`](README.md#security-scan-mix-mobsecurity_scan)
for the full layer list and the one-time `brew install` of external
scanners.
Expand Down
33 changes: 24 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
non-CA as an intermediate issuer), CVE-2026-94200 (TLS client ticket-store
poisoning) and CVE-2026-75538 (inet TCP driver overflow from an
unauthenticated peer). The crypto NIF was statically linked against
OpenSSL 3.4.0, which had 55 CVEs fixed in later 3.4.x releases, and the 3.4
OpenSSL 3.4.0, which had 56 CVEs fixed in later 3.4.x releases, and the 3.4
branch reached end of life on 2026-10-22. The new runtime is erts 17.1.1,
ssl 11.7.8, public_key 1.21.8 and inets 9.8.1, with OpenSSL 3.5.9 (LTS
until 2030). Elixir 1.20.1 and the bundled exqlite BEAMs are unchanged.
Expand Down Expand Up @@ -38,6 +38,19 @@
crypto, are gone. The fallback EPMD patch now checks the `run_daemon(g)`
call site rather than any `#ifndef NO_DAEMON`, which upstream `epmd.c`
already has.
A failed delete of those files now fails the deploy or build instead of
leaving them first on the code path.
- **A native build with a mob older than 0.9.25 stops before compiling
(MOB-493).** Such a mob hard-codes `erts-17.0`, which this runtime doesn't
have, so the app would build, install and die at boot. Hex resolution
already requires mob 0.9.25; the check covers a path or override `:mob`
dep (`mob_dir`, `--local` apps), for Android and the iOS simulator.
- **`mix mob.security_scan` honours `MOB_CACHE_DIR` (MOB-493).** It read
runtimes and its advisory cache from `~/.mob/cache` even when the runtime
the app uses was downloaded to `$MOB_CACHE_DIR`, so `--strict` could pass
without scanning it. It also now checks a bundle's recorded OTP release
(`otp_version` in `bundled_versions.exs`) against the tarball's
`releases/*/OTP_VERSION`.
- **Dev server: bandit 1.12.5 → 1.13.1 (MOB-482).** Fixes EEF-CVE-2026-105659
(a client trickling HTTP/2 `WINDOW_UPDATE` frames could pin stream processes
indefinitely) and EEF-CVE-2026-105660 (an HTTP/1 absolute-form request
Expand Down Expand Up @@ -87,12 +100,13 @@
`mix mob.deploy --native`.
- Host `.tool-versions` moves to erlang 29.1.2 (mob_dev, mob, mob_new and
generated apps).
- **MOB-495.** `mix mob.security_scan --strict` and `mix mob.release
--security-gate` now fail on a `5c9c69fc` runtime (critical ssl advisories
CVE-2026-89422 and CVE-2026-55953, OpenSSL 3.4 end of life). The
`d199a49a` runtime in this release passes them; delete the old
`~/.mob/cache/otp-*-5c9c69fc` dirs once no app on this machine still uses
that runtime.
- **MOB-495.** On mob_dev 0.7.29, `mix mob.security_scan --strict` and
`mix mob.release --security-gate` fail against its active `5c9c69fc`
runtime (critical ssl advisories CVE-2026-89422 and CVE-2026-55953,
OpenSSL 3.4 end of life). The `d199a49a` runtime in this release passes
them. Advisories are matched against the active runtime only, so leftover
`~/.mob/cache/otp-*-5c9c69fc` dirs get drift checks, not CVE checks:
delete them once no app on this machine still uses that runtime.
- **SQLite CVEs.** A project on exqlite 0.36.x (SQLite 3.51.3) now gets
CVE-2026-11822, CVE-2026-11824 (fixed in 3.53.2) and CVE-2025-70873 (fixed
in 3.52.0), which fail `--strict` and `--security-gate`. Matching is by
Expand All @@ -105,14 +119,15 @@
- **`mix mob.security_scan` reports CVEs in the bundled OTP and OpenSSL
(MOB-495).** The `bundled_runtime` layer used to print "verify upstream"
links, so the scan stayed green while the device runtime (OTP 29.0,
OpenSSL 3.4.0) fell ~46 erlang/otp advisories and 56 OpenSSL CVEs behind.
OpenSSL 3.4.0) fell 47 erlang/otp advisories and 56 OpenSSL CVEs behind.
For the active runtime's cached tarballs it now matches the OTP application
versions (`ssl`, `public_key`, `erts`, …) against the erlang/otp GitHub
security advisories, the OpenSSL version against its branch's
openssl-library.org vulnerability page, and the branch against
endoflife.date. Each affected advisory is a finding with the upstream
severity, installed and fixed versions and a link; an OpenSSL branch past
end of life is `:high`. Data is cached in `~/.mob/cache/advisories/` for
end of life is `:high`. Data is cached in `advisories/` under
`$MOB_CACHE_DIR` (default `~/.mob/cache`) for
24 h; offline the scan uses the cached copy (flagged stale) or, with none,
warns and lists the pages to check. See
`decisions/2026-10-22-bundled-runtime-upstream-advisories.md`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ severity, so these are `:medium`.

Each affected advisory is a finding with the upstream severity, the
installed and fixed versions, and a link. The data is cached in
`~/.mob/cache/advisories/` for 24 hours. Offline, the scan uses the
`advisories/` under `$MOB_CACHE_DIR` (default `~/.mob/cache`) for 24 hours. Offline, the scan uses the
cached copy (marked stale once it's older than that); with no copy it
warns and lists the pages to check by hand. A `GITHUB_TOKEN`/`GH_TOKEN`
or `gh auth token` is used when available; it isn't required. The Elixir
Expand Down
4 changes: 2 additions & 2 deletions decisions/2026-10-22-bundled-runtime-upstream-advisories.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
## Context

`mix mob.security_scan` ran in every release preflight while the device
runtime (OTP 29.0, OpenSSL 3.4.0, tarball `5c9c69fc`) fell about 46 erlang/otp
runtime (OTP 29.0, OpenSSL 3.4.0, tarball `5c9c69fc`) fell 47 erlang/otp
advisories and 56 OpenSSL CVEs behind, and OpenSSL 3.4 reached end of life. The
`bundled_runtime` layer only printed "verify upstream" links; its moduledoc said
no machine-readable feed existed. That is true of OSV for OTP (the EEF records
Expand Down Expand Up @@ -58,7 +58,7 @@ GitHub list must be non-empty with every advisory carrying `ghsa_id` and
`cycle`/`eol` maps; the OpenSSL page must carry its `Vulnerabilities <branch>`
heading, every CVE block must yield a known severity and an affected range, and
a page mentioning a CVE must yield at least one block. A body failing that is a
refresh failure, never "no advisories". The cache (`~/.mob/cache/advisories/`,
refresh failure, never "no advisories". The cache (`advisories/` under `$MOB_CACHE_DIR`, default `~/.mob/cache`,
24 h TTL by mtime) is used when fresh; on a failed refresh the cached copy is used
and the note says STALE with the reason; a fetch that couldn't be written says so;
with no copy the layer warns and prints the page to check by hand, and the drift
Expand Down
6 changes: 4 additions & 2 deletions decisions/2026-10-22-otp-29.1.2-runtime-no-host-otp-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

The `otp-5c9c69fc` runtime was OTP 29.0 with OpenSSL 3.4.0. It lacked 47 OTP
security fixes (two of them critical TLS client bugs), and OpenSSL 3.4.0
lacked 55 CVEs, with the 3.4 branch going end-of-life on 2026-10-22. The
lacked 56 CVEs, with the 3.4 branch going end-of-life on 2026-10-22. The
29.0.x line no longer gets patches, so the fix is the newest 29.x (29.1.2)
with OpenSSL on the 3.5 LTS branch.

Expand Down Expand Up @@ -63,7 +63,9 @@ upgrade alone fixed nothing in TLS. Review found two iOS siblings:
launcher, and in the iOS-sim fallback used when `-DERTS_VSN` isn't
passed, so it can't boot erts-17.1.1. mob 0.9.25 reads the directory at
runtime. As a Hex requirement it keeps an app's mob and mob_dev in step
without a doctor check.
without a doctor check. A path or override `:mob` dep bypasses it, so the
Android and iOS-simulator native builds also refuse a mob checkout that
lacks `android/jni/mob_erts_dir.zig` and `ios/mob_erts_dir.h`.

## Consequences

Expand Down
3 changes: 2 additions & 1 deletion guides/security_scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ per-branch [vulnerability page](https://openssl-library.org/news/vulnerabilities
and [endoflife.date](https://endoflife.date/openssl). 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 `~/.mob/cache/advisories/` for 24 hours, and a cached
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.

---
Expand Down
3 changes: 2 additions & 1 deletion lib/mix/tasks/mob.security_scan.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ defmodule Mix.Tasks.Mob.SecurityScan do
Mob's pre-built OTP tarballs (manifest + fingerprint verification,
erlang/otp GitHub advisories, OpenSSL vulnerability pages and
branch end-of-life), and the project's exqlite SQLite against
sqlite.org/cves.html; cached in `~/.mob/cache/advisories/`
sqlite.org/cves.html; cached in `advisories/` under `$MOB_CACHE_DIR`
(default `~/.mob/cache`)
* C source static analysis (semgrep, flawfinder)
* Kotlin static analysis (detekt)
* Swift static analysis (`xcodebuild analyze`)
Expand Down
70 changes: 52 additions & 18 deletions lib/mob_dev/deployer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -867,19 +867,22 @@ defmodule MobDev.Deployer do
:rooted ->
run_adb(["-s", serial, "shell", "mkdir -p #{android_beams_dir()}"])

case stale_host_ssl_rm_cmd(android_beams_dir()) do
nil -> :ok
cmd -> run_adb(["-s", serial, "shell", cmd])
with :ok <- remove_stale_host_ssl_android(serial, & &1) do
# Labelled by deploy_android's final relabel_otp_android/1.
Enum.reduce_while(beam_dirs, :ok, fn dir, _ ->
case run_adb([
"-s",
serial,
"push",
"#{Path.expand(dir)}/.",
"#{android_beams_dir()}/"
]) do
{:ok, _} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, "push failed: #{reason}"}}
end
end)
end

# Labelled by deploy_android's final relabel_otp_android/1.
Enum.reduce_while(beam_dirs, :ok, fn dir, _ ->
case run_adb(["-s", serial, "push", "#{Path.expand(dir)}/.", "#{android_beams_dir()}/"]) do
{:ok, _} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, "push failed: #{reason}"}}
end
end)

:not_rooted ->
# Fall back to run-as tar (non-rooted physical devices).
push_beams_android_runas(serial, beam_dirs)
Expand Down Expand Up @@ -927,10 +930,12 @@ defmodule MobDev.Deployer do
"run-as #{android_package()} mkdir -p #{android_beams_dir()}"
])

case stale_host_ssl_rm_cmd(android_beams_dir()) do
nil -> :ok
cmd -> run_adb(["-s", serial, "shell", "run-as #{android_package()} sh -c '#{cmd}'"])
end
with {:error, _} = error <-
remove_stale_host_ssl_android(
serial,
&"run-as #{android_package()} sh -c '#{&1}'"
),
do: throw(error)

# Redirect stderr and always exit 0: Android's Toybox tar cannot chown to
# macOS UID 501 and exits 1, but the files are extracted correctly.
Expand Down Expand Up @@ -1058,7 +1063,7 @@ defmodule MobDev.Deployer do

try do
File.mkdir_p!(ios_beams_dir())
remove_stale_host_ssl!(ios_beams_dir())
with {:error, _} = error <- remove_stale_host_ssl(ios_beams_dir()), do: throw(error)

# Use rsync rather than cp -r for two reasons that hit Nix users hard:
#
Expand Down Expand Up @@ -2023,8 +2028,37 @@ defmodule MobDev.Deployer do
def stale_host_ssl_rm_cmd(beams_dir, files),
do: ~s(cd "#{beams_dir}" && rm -f #{Enum.join(files, " ")})

defp remove_stale_host_ssl!(beams_dir) do
Enum.each(stale_host_ssl_files(), &File.rm(Path.join(beams_dir, &1)))
# A file left behind stays first on -pa and shadows the runtime's ssl or
# crypto, so a failed delete fails the deploy instead of passing silently.
defp remove_stale_host_ssl_android(serial, wrap) do
case stale_host_ssl_rm_cmd(android_beams_dir()) do
nil ->
:ok

cmd ->
case run_adb(["-s", serial, "shell", wrap.(cmd)]) do
{:ok, _} -> :ok
{:error, reason} -> {:error, "removing stale host ssl/crypto BEAMs failed: #{reason}"}
end
end
end

@doc false
@spec remove_stale_host_ssl(Path.t()) :: :ok | {:error, String.t()}
def remove_stale_host_ssl(beams_dir) do
Enum.reduce_while(stale_host_ssl_files(), :ok, fn name, :ok ->
path = Path.join(beams_dir, name)

case File.rm(path) do
result when result in [:ok, {:error, :enoent}] ->
{:cont, :ok}

{:error, reason} ->
{:halt,
{:error,
"removing stale host ssl/crypto BEAM #{path} failed: #{:file.format_error(reason)}"}}
end
end)
end

defp count_beams(beam_dirs) do
Expand Down
35 changes: 31 additions & 4 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ defmodule MobDev.NativeBuild do
mob_dir = Path.expand(cfg[:mob_dir])
warn_missing_app_lifecycle_hooks(mob_dir)

with {:ok, otp} <- ensure_android_otps(abis),
with :ok <- mob_finds_erts(mob_dir),
{:ok, otp} <- ensure_android_otps(abis),
{:ok, python_android_bundle} <- maybe_ensure_python_android_bundle(),
:ok <- each_ok(otp, fn {abi, dir} -> ensure_jni_libs(dir, abi) end),
:ok <- ensure_python_android_libs(python_android_bundle),
Expand Down Expand Up @@ -460,6 +461,26 @@ defmodule MobDev.NativeBuild do
|> Enum.uniq()
end

# mob < 0.9.25 hard-codes erts-17.0 in the Android launcher and in the
# iOS-simulator fallback, but the OTP 29.1.2 runtime is erts-17.1.1: the
# app would build, install and die at boot (MOB-493). Hex resolution keeps
# mob at ~> 0.9.25; a path or override :mob dep (mob_dir, --local apps)
# doesn't, so check the native source itself. Physical-iOS and release
# builds pass ERTS_VSN at compile time and don't need it.
@doc false
@spec mob_finds_erts(Path.t()) :: :ok | {:error, String.t()}
def mob_finds_erts(mob_dir) do
if File.regular?(Path.join(mob_dir, "android/jni/mob_erts_dir.zig")) and
File.regular?(Path.join(mob_dir, "ios/mob_erts_dir.h")) do
:ok
else
{:error,
"mob at #{mob_dir} is older than 0.9.25: its launcher hard-codes erts-17.0, " <>
"and this mob_dev's runtime (OTP 29.1.2) is erts-17.1.1, so the app would " <>
"die at boot. Update that checkout, or use {:mob, \"~> 0.9.25\"} from Hex."}
end
end

# An app that upgraded mob but kept its pre-0.9.6 MainActivity.kt / beam_jni.c
# builds and runs fine and never gets a Mob.Device :app event. Say so where
# the upgrade is exercised, not only in `mix mob.doctor`.
Expand Down Expand Up @@ -1958,8 +1979,12 @@ defmodule MobDev.NativeBuild do

# ── iOS ──────────────────────────────────────────────────────────────────────

defp build_ios(cfg, device_ids) do
@doc false
@spec build_ios(keyword(), [String.t()] | String.t() | nil) ::
{:ok, String.t()} | {:error, String.t(), term()}
def build_ios(cfg, device_ids) do
with :ok <- check_path(cfg[:mob_dir], "mob_dir"),
:ok <- mob_finds_erts(Path.expand(cfg[:mob_dir])),
:ok <- check_path(cfg[:elixir_lib], "elixir_lib"),
{:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_sim(),
{:ok, python_bundle} <- maybe_ensure_python_bundle(),
Expand Down Expand Up @@ -2064,8 +2089,10 @@ defmodule MobDev.NativeBuild do
File.mkdir_p!(beams_dir)
chmod_writable(beams_dir)

# Host ssl BEAMs an older mob_dev copied here shadow the runtime's ssl.
Enum.each(MobDev.Deployer.stale_host_ssl_files(), &File.rm(Path.join(beams_dir, &1)))
# Host ssl BEAMs and the crypto shim an older mob_dev put here shadow
# the runtime's ssl and crypto.
with {:error, message} <- MobDev.Deployer.remove_stale_host_ssl(beams_dir),
do: Mix.raise(message)

# The generated mob_app_config.beam (the project's config/*.exs for the
# device, see MobDev.AppConfig) lands in the app's own ebin, which the
Expand Down
9 changes: 5 additions & 4 deletions lib/mob_dev/security_scan/bundled_runtime/advisory_feed.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule MobDev.SecurityScan.BundledRuntime.AdvisoryFeed do

The scan has to complete offline and must not hammer the unauthenticated
GitHub API (60 requests/hour), so every source goes through a file cache
at `~/.mob/cache/advisories/`:
at `advisories/` in the runtime cache (`$MOB_CACHE_DIR`, default `~/.mob/cache`):

1. a cached copy younger than the TTL (default 24 h) is used as-is;
2. otherwise the source is fetched, validated, and written to the cache;
Expand All @@ -18,7 +18,7 @@ defmodule MobDev.SecurityScan.BundledRuntime.AdvisoryFeed do

## Options

* `:advisory_cache_dir` — cache directory (default `~/.mob/cache/advisories`)
* `:advisory_cache_dir` — cache directory (default `default_cache_dir/0`)
* `:advisory_ttl` — seconds a cached copy counts as fresh (default 86_400)
* `:advisory_http_get` — `(url, headers) -> {:ok, status, headers, body} |
{:error, reason}`; defaults to `:httpc`. Tests inject a stub.
Expand Down Expand Up @@ -46,9 +46,10 @@ defmodule MobDev.SecurityScan.BundledRuntime.AdvisoryFeed do
@eol_url "https://endoflife.date/api/openssl.json"
@max_pages 20

@doc "Default cache directory for advisory data."
@doc "Default cache directory for advisory data: `advisories/` in the runtime cache."
@spec default_cache_dir() :: Path.t()
def default_cache_dir, do: Path.join([System.user_home() || "/", ".mob", "cache", "advisories"])
def default_cache_dir,
do: Path.join(MobDev.SecurityScan.BundledRuntime.Fingerprint.cache_dir(), "advisories")

@doc """
Load one source's decoded data: `[advisory]` for `:otp`, the parsed
Expand Down
18 changes: 13 additions & 5 deletions lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
@moduledoc """
Extracts versions from `~/.mob/cache/otp-*-{hash}/` and from exqlite
Extracts versions from `otp-*-{hash}/` in the runtime cache
(`$MOB_CACHE_DIR`, default `~/.mob/cache/`) and from exqlite
C sources in a project's `deps/`.

This module is the *receipt* side of the manifest-first design.
Expand All @@ -13,8 +14,6 @@ defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
and nothing else.
"""

@cache_dir Path.join([System.user_home() || "/", ".mob", "cache"])

@typedoc """
One cached tarball found on disk.
`:platform` is decoded from the directory name; `:hash` is the trailing
Expand Down Expand Up @@ -42,7 +41,7 @@ defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
}

@doc """
Locate every cached OTP tarball under `~/.mob/cache/`.
Locate every cached OTP tarball in `cache_dir/0`.

Returns a list of `tarball/0` entries, sorted by platform then hash
for stable output. Filters out anything that isn't a directory or
Expand All @@ -52,7 +51,7 @@ defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
"""
@spec locate_cached_tarballs(keyword()) :: [tarball()]
def locate_cached_tarballs(opts \\ []) do
cache_dir = Keyword.get(opts, :cache_dir, @cache_dir)
cache_dir = Keyword.get_lazy(opts, :cache_dir, &cache_dir/0)

case File.ls(cache_dir) do
{:ok, entries} ->
Expand Down Expand Up @@ -86,6 +85,15 @@ defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
end
end

@doc """
The runtime cache `MobDev.OtpDownloader` extracts tarballs into:
`$MOB_CACHE_DIR`, else `~/.mob/cache`. Read per call, not at compile time.
"""
@spec cache_dir() :: Path.t()
def cache_dir do
System.get_env("MOB_CACHE_DIR") || Path.join([System.user_home() || "/", ".mob", "cache"])
end

@doc """
Fingerprint a single tarball directory. Returns the versions
extracted from disk.
Expand Down
Loading
Loading