From 1abd260c267c35a72b3483bac7902b5d1b6a6a1c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 22 Oct 2026 23:42:38 -0600 Subject: [PATCH 1/3] Release-gate fixes for 0.7.30: MOB_CACHE_DIR in security_scan, OTP version drift, mob ERTS preflight, failed stale-BEAM deletes fail (MOB-493) --- CHANGELOG.md | 33 ++++++--- ...-22-otp-29.1.2-runtime-no-host-otp-apps.md | 6 +- lib/mix/tasks/mob.security_scan.ex | 3 +- lib/mob_dev/deployer.ex | 70 ++++++++++++++----- lib/mob_dev/native_build.ex | 30 +++++++- .../bundled_runtime/advisory_feed.ex | 9 +-- .../bundled_runtime/fingerprint.ex | 18 +++-- .../security_scan/layers/bundled_runtime.ex | 13 +++- test/mob_dev/native_build_test.exs | 18 +++++ .../fingerprint_cache_dir_test.exs | 28 ++++++++ .../layers/bundled_runtime_test.exs | 28 +++++++- 11 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 test/mob_dev/security_scan/bundled_runtime/fingerprint_cache_dir_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b127a7d9..b1203c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 @@ -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 @@ -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`. diff --git a/decisions/2026-10-22-otp-29.1.2-runtime-no-host-otp-apps.md b/decisions/2026-10-22-otp-29.1.2-runtime-no-host-otp-apps.md index 5e3b4edb..8b35add8 100644 --- a/decisions/2026-10-22-otp-29.1.2-runtime-no-host-otp-apps.md +++ b/decisions/2026-10-22-otp-29.1.2-runtime-no-host-otp-apps.md @@ -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. @@ -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 diff --git a/lib/mix/tasks/mob.security_scan.ex b/lib/mix/tasks/mob.security_scan.ex index a0033110..31831ce1 100644 --- a/lib/mix/tasks/mob.security_scan.ex +++ b/lib/mix/tasks/mob.security_scan.ex @@ -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`) diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 83a4aec1..d34a2e75 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -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) @@ -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. @@ -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: # @@ -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 diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 89fa29d3..391b61a6 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -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), @@ -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`. @@ -1960,6 +1981,7 @@ defmodule MobDev.NativeBuild do defp 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(), @@ -2064,8 +2086,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 diff --git a/lib/mob_dev/security_scan/bundled_runtime/advisory_feed.ex b/lib/mob_dev/security_scan/bundled_runtime/advisory_feed.ex index 0c1f89c9..88ac2479 100644 --- a/lib/mob_dev/security_scan/bundled_runtime/advisory_feed.ex +++ b/lib/mob_dev/security_scan/bundled_runtime/advisory_feed.ex @@ -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; @@ -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. @@ -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 diff --git a/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex b/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex index dc70c281..e95272eb 100644 --- a/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex +++ b/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex @@ -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. @@ -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 @@ -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 @@ -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} -> @@ -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. diff --git a/lib/mob_dev/security_scan/layers/bundled_runtime.ex b/lib/mob_dev/security_scan/layers/bundled_runtime.ex index fedb30b0..506ec7d4 100644 --- a/lib/mob_dev/security_scan/layers/bundled_runtime.ex +++ b/lib/mob_dev/security_scan/layers/bundled_runtime.ex @@ -18,7 +18,8 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntime do ## What it does - 1. **Fingerprint** — locate cached tarballs at `~/.mob/cache/otp-*-{hash}/`, + 1. **Fingerprint** — locate cached tarballs at `otp-*-{hash}/` in the + runtime cache (`$MOB_CACHE_DIR`, default `~/.mob/cache`), extract real versions of OpenSSL, ERTS, Elixir, and the bundled exqlite BEAMs. @@ -99,7 +100,7 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntime do notes = [ - "no cached OTP tarballs found at ~/.mob/cache/", + "no cached OTP tarballs found in #{Keyword.get_lazy(opts, :cache_dir, &Fingerprint.cache_dir/0)}", "run `mix mob.deploy --native` from a Mob app to populate the cache" ] ++ sqlite_notes @@ -193,6 +194,7 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntime do defp check_bundle(tb, versions, bundle, project_exqlite) do fields = [ {:erts, "ERTS"}, + {:otp_version, "OTP"}, {:elixir, "Elixir"}, {:openssl, "OpenSSL"}, {:exqlite_beam, "exqlite (BEAM)"} @@ -211,6 +213,13 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntime do {:exqlite_beam, label} when is_nil(exqlite_expected) -> stray_exqlite(tb, label, versions.exqlite_beam, project_exqlite) + # Bundles recorded before otp_version existed don't claim one. + {:otp_version, label} -> + case expected_for(bundle, tb.platform, :otp_version) do + nil -> [] + expected -> compare_field(tb, label, :otp_version, expected, versions.otp) + end + {key, label} -> compare_field(tb, label, key, expected_for(bundle, tb.platform, key), versions[key]) end) diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 9f9d7e6a..477fea34 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2827,4 +2827,22 @@ defmodule MobDev.NativeBuildTest do assert_raise ArgumentError, ~r/style :sfonts declares font "priv\/fonts\/Gone.ttf"/, collect end end + + # MOB-493: a path/override mob older than 0.9.25 hard-codes erts-17.0 and + # would build fine, then die at boot on the erts-17.1.1 runtime. + describe "mob_finds_erts/1" do + @tag :tmp_dir + test "accepts a mob that finds the ERTS dir at runtime, rejects one that doesn't", + %{tmp_dir: mob_dir} do + assert {:error, message} = NativeBuild.mob_finds_erts(mob_dir) + assert message =~ "older than 0.9.25" + + for rel <- ["android/jni/mob_erts_dir.zig", "ios/mob_erts_dir.h"] do + File.mkdir_p!(Path.dirname(Path.join(mob_dir, rel))) + File.write!(Path.join(mob_dir, rel), "") + end + + assert NativeBuild.mob_finds_erts(mob_dir) == :ok + end + end end diff --git a/test/mob_dev/security_scan/bundled_runtime/fingerprint_cache_dir_test.exs b/test/mob_dev/security_scan/bundled_runtime/fingerprint_cache_dir_test.exs new file mode 100644 index 00000000..4d66e2a8 --- /dev/null +++ b/test/mob_dev/security_scan/bundled_runtime/fingerprint_cache_dir_test.exs @@ -0,0 +1,28 @@ +defmodule MobDev.SecurityScan.BundledRuntime.FingerprintCacheDirTest do + # Sets MOB_CACHE_DIR, which is process-global. + use ExUnit.Case, async: false + + alias MobDev.SecurityScan.BundledRuntime.{AdvisoryFeed, Fingerprint} + + @moduletag :tmp_dir + + setup do + previous = System.get_env("MOB_CACHE_DIR") + + on_exit(fn -> + if previous, + do: System.put_env("MOB_CACHE_DIR", previous), + else: System.delete_env("MOB_CACHE_DIR") + end) + end + + # OtpDownloader extracts runtimes into $MOB_CACHE_DIR; a scan that read + # ~/.mob/cache instead audited runtimes the app doesn't use. + test "the scan reads the runtime cache from MOB_CACHE_DIR", %{tmp_dir: dir} do + File.mkdir_p!(Path.join(dir, "otp-android-d199a49a")) + System.put_env("MOB_CACHE_DIR", dir) + + assert [%{platform: :android, hash: "d199a49a"}] = Fingerprint.locate_cached_tarballs() + assert AdvisoryFeed.default_cache_dir() == Path.join(dir, "advisories") + end +end diff --git a/test/mob_dev/security_scan/layers/bundled_runtime_test.exs b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs index 98eaac54..ab78e7d9 100644 --- a/test/mob_dev/security_scan/layers/bundled_runtime_test.exs +++ b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs @@ -45,7 +45,10 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntimeTest do File.mkdir_p!(Path.join([path, "lib", "exqlite-#{exqlite_vsn}"])) end - if otp_vsn = opts[:otp] do + # A real tarball always has releases/*/OTP_VERSION; default to the + # active bundle's so only tests about it need to say so. + if otp_vsn = + Keyword.get(opts, :otp, MobDev.SecurityScan.BundledVersions.active()[:otp_version]) do release_dir = Path.join([path, "releases", otp_vsn |> String.split(".") |> hd()]) File.mkdir_p!(release_dir) File.write!(Path.join(release_dir, "OTP_VERSION"), otp_vsn <> "\n") @@ -116,6 +119,29 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntimeTest do assert finding.title =~ "binary=9.9.9-rc.1" end + test ":high finding when the OTP release drifts from the manifest's otp_version", %{ + tmp_dir: dir + } do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :android, real_hash(), + openssl: bundle.openssl, + elixir: bundle.elixir, + exqlite: bundle.exqlite_beam, + otp: "29.0" + ) + + result = run_offline(dir, cache) + + assert [%Finding{severity: :high, id: "MOB-DRIFT-android-otp_version"} = finding] = + Enum.filter(result.findings, &String.starts_with?(&1.id, "MOB-DRIFT")) + + assert finding.title =~ "OTP manifest=#{bundle.otp_version} binary=29.0" + end + test "per-platform override suppresses missing-artifact drift", %{tmp_dir: dir} do # iOS sim does NOT ship exqlite per the active manifest. Building a # tarball without exqlite should NOT generate a drift finding for it. From 64769f6010c274c3e0b3f3bd3bafe48dece4973f Mon Sep 17 00:00:00 2001 From: GenericJam Date: Thu, 22 Oct 2026 23:53:23 -0600 Subject: [PATCH 2/3] Test the ERTS preflight wiring and stale-BEAM delete failures; require otp_version on the active bundle (MOB-493) --- lib/mob_dev/native_build.ex | 5 +- lib/mob_dev/security_scan/bundled_versions.ex | 8 ++ test/mob_dev/deployer_stale_ssl_test.exs | 104 ++++++++++++++++++ test/mob_dev/native_build_test.exs | 16 +++ .../security_scan/bundled_versions_test.exs | 2 + 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 test/mob_dev/deployer_stale_ssl_test.exs diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 391b61a6..95f1f816 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1979,7 +1979,10 @@ 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"), diff --git a/lib/mob_dev/security_scan/bundled_versions.ex b/lib/mob_dev/security_scan/bundled_versions.ex index 7baf3b00..9a34d9ca 100644 --- a/lib/mob_dev/security_scan/bundled_versions.ex +++ b/lib/mob_dev/security_scan/bundled_versions.ex @@ -94,6 +94,14 @@ defmodule MobDev.SecurityScan.BundledVersions do end Enum.each(bundles, fn {h, bundle} -> validate_bundle!(h, bundle) end) + + # The scan checks otp_version against the tarball's OTP_VERSION, and the + # advisory match uses it; bundles recorded before it existed don't have + # one, but the active bundle must. + unless is_binary(bundles[hash][:otp_version]) do + raise "bundled versions manifest: active bundle #{inspect(hash)} missing required field :otp_version" + end + :ok end diff --git a/test/mob_dev/deployer_stale_ssl_test.exs b/test/mob_dev/deployer_stale_ssl_test.exs new file mode 100644 index 00000000..b37f31d8 --- /dev/null +++ b/test/mob_dev/deployer_stale_ssl_test.exs @@ -0,0 +1,104 @@ +defmodule MobDev.DeployerStaleSslTest do + @moduledoc """ + A deploy deletes the host ssl BEAMs and crypto shim an older mob_dev left + in the app's beams dir, which is first on -pa (MOB-493). If that delete + fails the files keep shadowing the runtime's ssl and crypto, so the deploy + must fail rather than report success. + + The Android cases run `Deployer.deploy_all/1` against a fake `adb` on PATH + whose `rm` fails. async: false — PATH is process-global. + """ + use ExUnit.Case, async: false + + alias MobDev.{Deployer, Device} + + @moduletag :tmp_dir + + # FAKE_ADB_ROOT=no answers `adb root` the way a production phone does, so + # the deploy takes the run-as path. + @fake_adb """ + #!/bin/sh + set -f + printf '%s\\n' "$*" >> "$FAKE_ADB_DIR/calls" + case "$*" in + *"pm list packages "*) for a; do last=$a; done; echo "package:$last" ;; + *" root") + if [ "$FAKE_ADB_ROOT" = no ]; then + echo "adbd cannot run as root in production builds" + else + echo "adbd is already running as root" + fi ;; + *"id -u") echo 0 ;; + *"rm -f "*) echo "rm: ssl.beam: Permission denied"; exit 1 ;; + esac + exit 0 + """ + + setup %{tmp_dir: tmp} do + bin = Path.join(tmp, "bin") + File.mkdir_p!(bin) + adb = Path.join(bin, "adb") + File.write!(adb, @fake_adb) + File.chmod!(adb, 0o755) + + path = System.get_env("PATH") + System.put_env("PATH", bin <> ":" <> path) + System.put_env("FAKE_ADB_DIR", tmp) + + on_exit(fn -> + System.put_env("PATH", path) + System.delete_env("FAKE_ADB_DIR") + System.delete_env("FAKE_ADB_ROOT") + end) + + :ok + end + + for {label, root} <- [{"rooted", "yes"}, {"run-as", "no"}] do + test "a failed delete fails the #{label} Android deploy before any BEAM is pushed", + %{tmp_dir: tmp} do + System.put_env("FAKE_ADB_ROOT", unquote(root)) + + emulator = %Device{ + name: "emu", + serial: "emulator-5620", + platform: :android, + type: :emulator + } + + ExUnit.CaptureIO.capture_io(fn -> + assert {[], [failed], _} = + Deployer.deploy_all( + devices: [emulator], + platforms: [:android], + restart: false, + force_fs: true + ) + + assert inspect(failed) =~ "removing stale host ssl/crypto BEAMs failed" + end) + + calls = tmp |> Path.join("calls") |> File.read!() |> String.split("\n", trim: true) + rm = Enum.find_index(calls, &(&1 =~ "rm -f ")) + assert is_integer(rm), "the stale-file delete never ran" + refute Enum.any?(Enum.drop(calls, rm + 1), &(&1 =~ ~r/ push |tar xf/)) + end + end + + describe "remove_stale_host_ssl/1 (iOS simulator deploy and native build)" do + test "files that aren't there are fine", %{tmp_dir: tmp} do + assert Deployer.remove_stale_host_ssl(tmp) == :ok + end + + test "a file that can't be deleted is an error naming it", %{tmp_dir: tmp} do + beams = Path.join(tmp, "beams") + File.mkdir_p!(beams) + File.write!(Path.join(beams, "crypto.beam"), "") + File.chmod!(beams, 0o555) + on_exit(fn -> File.chmod(beams, 0o755) end) + + assert {:error, message} = Deployer.remove_stale_host_ssl(beams) + assert message =~ Path.join(beams, "crypto.beam") + end + end +end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 477fea34..7a9bccab 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -2844,5 +2844,21 @@ defmodule MobDev.NativeBuildTest do assert NativeBuild.mob_finds_erts(mob_dir) == :ok end + + @tag :tmp_dir + test "the Android and iOS-simulator builds stop on such a mob before fetching a runtime", + %{tmp_dir: mob_dir} do + ExUnit.CaptureIO.capture_io(fn -> + assert {:error, android} = + NativeBuild.build_android_native([mob_dir: mob_dir], abis: ["arm64-v8a"]) + + assert android =~ "older than 0.9.25" + + assert {:error, "iOS", ios} = + NativeBuild.build_ios([mob_dir: mob_dir, elixir_lib: mob_dir], []) + + assert ios =~ "older than 0.9.25" + end) + end end end diff --git a/test/mob_dev/security_scan/bundled_versions_test.exs b/test/mob_dev/security_scan/bundled_versions_test.exs index de2eed66..66970584 100644 --- a/test/mob_dev/security_scan/bundled_versions_test.exs +++ b/test/mob_dev/security_scan/bundled_versions_test.exs @@ -16,6 +16,8 @@ defmodule MobDev.SecurityScan.BundledVersionsTest do assert is_binary(bundle.erts) and byte_size(bundle.erts) > 0 assert is_binary(bundle.otp_release) and byte_size(bundle.otp_release) > 0 + # The scan's OTP drift check and advisory match key off it. + assert is_binary(bundle.otp_version) and byte_size(bundle.otp_version) > 0 assert is_binary(bundle.elixir) and byte_size(bundle.elixir) > 0 assert is_binary(bundle.openssl) and byte_size(bundle.openssl) > 0 assert is_binary(bundle.exqlite_beam) and byte_size(bundle.exqlite_beam) > 0 From 7055182ca97d9e5f7dafbefc352138e3ef47d633 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 23 Oct 2026 00:01:54 -0600 Subject: [PATCH 3/3] Test manifest validation and the legacy-bundle OTP skip; MOB_CACHE_DIR and count fixes in docs (MOB-493) --- AGENTS.md | 5 +- README.md | 2 +- ...-22-bundled-runtime-upstream-advisories.md | 4 +- guides/security_scan.md | 3 +- lib/mob_dev/security_scan/bundled_versions.ex | 9 ++-- priv/security/bundled_versions.exs | 5 +- scripts/release/openssl/README.md | 2 +- test/mob_dev/deployer_stale_ssl_test.exs | 2 +- .../security_scan/bundled_versions_test.exs | 47 ++++++++++--------- .../layers/bundled_runtime_test.exs | 21 +++++++++ 10 files changed, 65 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c8c0da0..46482e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index b5110225..e698619a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/decisions/2026-10-22-bundled-runtime-upstream-advisories.md b/decisions/2026-10-22-bundled-runtime-upstream-advisories.md index 1bbb6905..9d7ca5d3 100644 --- a/decisions/2026-10-22-bundled-runtime-upstream-advisories.md +++ b/decisions/2026-10-22-bundled-runtime-upstream-advisories.md @@ -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 @@ -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 ` 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 diff --git a/guides/security_scan.md b/guides/security_scan.md index 41c37f8a..f832739b 100644 --- a/guides/security_scan.md +++ b/guides/security_scan.md @@ -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. --- diff --git a/lib/mob_dev/security_scan/bundled_versions.ex b/lib/mob_dev/security_scan/bundled_versions.ex index 9a34d9ca..9160f4ac 100644 --- a/lib/mob_dev/security_scan/bundled_versions.ex +++ b/lib/mob_dev/security_scan/bundled_versions.ex @@ -87,8 +87,11 @@ defmodule MobDev.SecurityScan.BundledVersions do Map.fetch!(manifest.bundles, manifest.active_hash) end - defp validate!(%{active_hash: hash, bundles: bundles}) - when is_binary(hash) and is_map(bundles) do + @doc false + # Public for tests: load/0 always reads priv/security/bundled_versions.exs. + @spec validate!(term()) :: :ok + def validate!(%{active_hash: hash, bundles: bundles}) + when is_binary(hash) and is_map(bundles) do unless Map.has_key?(bundles, hash) do raise "bundled versions manifest: active_hash #{inspect(hash)} not found in :bundles" end @@ -105,7 +108,7 @@ defmodule MobDev.SecurityScan.BundledVersions do :ok end - defp validate!(other) do + def validate!(other) do raise "bundled versions manifest must be %{active_hash: ..., bundles: %{...}}; got #{inspect(other)}" end diff --git a/priv/security/bundled_versions.exs b/priv/security/bundled_versions.exs index b423d8fa..bb199aad 100644 --- a/priv/security/bundled_versions.exs +++ b/priv/security/bundled_versions.exs @@ -3,8 +3,9 @@ # # `:active_hash` MUST match `@otp_hash` in # `lib/mob_dev/otp_downloader.ex` — the security-scan layer -# fingerprints `~/.mob/cache/otp-*-{hash}/` against the bundle -# entry for this hash and raises if they disagree. +# fingerprints `otp-*-{hash}/` in the runtime cache (`$MOB_CACHE_DIR`, +# default `~/.mob/cache`) against the bundle entry for this hash and +# raises if they disagree. The active bundle must record `:otp_version`. # # When updating: # diff --git a/scripts/release/openssl/README.md b/scripts/release/openssl/README.md index 2a4757a1..9172c43b 100644 --- a/scripts/release/openssl/README.md +++ b/scripts/release/openssl/README.md @@ -31,7 +31,7 @@ git clone --depth 1 --branch openssl-3.5.9 https://github.com/openssl/openssl.gi 3.5 is the current LTS branch (supported until 2030-04-08). Before 2026-10 this pinned 3.4.0 and never took a 3.4.x point release; 3.4 went -end-of-life on 2026-10-22 with 55 CVEs unpatched in 3.4.0 (MOB-494). +end-of-life on 2026-10-22 with 56 CVEs unpatched in 3.4.0 (MOB-494). Take every 3.5.x point release: bump the tag here, rebuild all targets, and update `openssl` / `openssl_release_date` in `priv/security/bundled_versions.exs`. diff --git a/test/mob_dev/deployer_stale_ssl_test.exs b/test/mob_dev/deployer_stale_ssl_test.exs index b37f31d8..fbf464ed 100644 --- a/test/mob_dev/deployer_stale_ssl_test.exs +++ b/test/mob_dev/deployer_stale_ssl_test.exs @@ -55,7 +55,7 @@ defmodule MobDev.DeployerStaleSslTest do end for {label, root} <- [{"rooted", "yes"}, {"run-as", "no"}] do - test "a failed delete fails the #{label} Android deploy before any BEAM is pushed", + test "a failed delete fails the #{label} Android deploy before any BEAM is installed", %{tmp_dir: tmp} do System.put_env("FAKE_ADB_ROOT", unquote(root)) diff --git a/test/mob_dev/security_scan/bundled_versions_test.exs b/test/mob_dev/security_scan/bundled_versions_test.exs index 66970584..de54ca5e 100644 --- a/test/mob_dev/security_scan/bundled_versions_test.exs +++ b/test/mob_dev/security_scan/bundled_versions_test.exs @@ -36,28 +36,31 @@ defmodule MobDev.SecurityScan.BundledVersionsTest do end end - describe "validation (synthetic manifest written to a tmp file)" do - @tag :tmp_dir - test "valid manifest loads", %{tmp_dir: dir} do - path = Path.join(dir, "manifest.exs") - - File.write!(path, """ - %{ - active_hash: "abcdef", - bundles: %{ - "abcdef" => %{ - erts: "16.3", - otp_release: "28", - elixir: "1.19.5", - openssl: "3.4.0", - exqlite_beam: "0.36.0" - } - } - } - """) - - {manifest, _} = Code.eval_file(path) - assert manifest.active_hash == "abcdef" + describe "validate!/1" do + @base %{ + erts: "17.1.1", + otp_release: "29", + elixir: "1.20.1", + openssl: "3.5.9", + exqlite_beam: "0.36.0" + } + + test "the active bundle must record its OTP release; older bundles needn't" do + legacy = %{"old" => @base} + + assert BundledVersions.validate!(%{ + active_hash: "new", + bundles: Map.put(legacy, "new", Map.put(@base, :otp_version, "29.1.2")) + }) == :ok + + assert_raise RuntimeError, + ~r/active bundle "new" missing required field :otp_version/, + fn -> + BundledVersions.validate!(%{ + active_hash: "new", + bundles: Map.put(legacy, "new", @base) + }) + end end end end diff --git a/test/mob_dev/security_scan/layers/bundled_runtime_test.exs b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs index ab78e7d9..45622532 100644 --- a/test/mob_dev/security_scan/layers/bundled_runtime_test.exs +++ b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs @@ -142,6 +142,27 @@ defmodule MobDev.SecurityScan.Layers.BundledRuntimeTest do assert finding.title =~ "OTP manifest=#{bundle.otp_version} binary=29.0" end + test "a cached older bundle that records no otp_version gets no OTP drift finding", %{ + tmp_dir: dir + } do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + {:ok, old} = MobDev.SecurityScan.BundledVersions.for_hash("5c9c69fc") + refute Map.has_key?(old, :otp_version) + + build_tarball(cache, :android, "5c9c69fc", + openssl: old.openssl, + elixir: old.elixir, + exqlite: old.exqlite_beam, + otp: "29.0" + ) + + result = run_offline(dir, cache) + + refute Enum.any?(result.findings, &(&1.id =~ "otp_version")) + end + test "per-platform override suppresses missing-artifact drift", %{tmp_dir: dir} do # iOS sim does NOT ship exqlite per the active manifest. Building a # tarball without exqlite should NOT generate a drift finding for it.