Packages
mob_dev
0.6.22
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex
defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do
@moduledoc """
Extracts versions from `~/.mob/cache/otp-*-{hash}/` and from exqlite
C sources in a project's `deps/`.
This module is the *receipt* side of the manifest-first design.
`BundledVersions` records what we *claim* shipped; `Fingerprint`
reads what's *actually* on disk. The bundled-runtime scan layer
asserts they agree.
Pure functions only — no advisory feed lookups, no severity
judgements. The fingerprinter answers "what version is this binary?"
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
OTP commit hash from `MobDev.OtpDownloader`.
"""
@type tarball :: %{
platform: :android | :android_arm32 | :ios_sim | :ios_device,
hash: String.t(),
path: Path.t()
}
@typedoc """
Versions extracted from a single tarball. Any field may be `nil` if
fingerprinting failed for that artifact (e.g. libcrypto.a was stripped
in an unexpected way). The layer reports such cases as findings, not
silent failures.
"""
@type tarball_versions :: %{
erts: String.t() | nil,
elixir: String.t() | nil,
openssl: String.t() | nil,
exqlite_beam: String.t() | nil
}
@doc """
Locate every cached OTP tarball under `~/.mob/cache/`.
Returns a list of `tarball/0` entries, sorted by platform then hash
for stable output. Filters out anything that isn't a directory or
doesn't match the `otp-{platform}-{hash}` naming scheme.
Pass `:cache_dir` to override the default path (used in tests).
"""
@spec locate_cached_tarballs(keyword()) :: [tarball()]
def locate_cached_tarballs(opts \\ []) do
cache_dir = Keyword.get(opts, :cache_dir, @cache_dir)
case File.ls(cache_dir) do
{:ok, entries} ->
entries
|> Enum.map(&Path.join(cache_dir, &1))
|> Enum.filter(&File.dir?/1)
|> Enum.flat_map(&decode_tarball_dir/1)
|> Enum.sort_by(&{&1.platform, &1.hash})
{:error, _} ->
[]
end
end
defp decode_tarball_dir(path) do
# Regex.compile!/1 (not ~r/.../) to sidestep OTP 28.0's `:re.import/1`
# undefined-function bug on sigil-precompiled regexes loaded from beam
# files. See MobDev.NdkVersion.project_pinned/1 for the same workaround.
pattern = Regex.compile!("^otp-(android-arm32|android|ios-sim|ios-device)-([0-9a-f]+)$")
case Regex.run(pattern, Path.basename(path)) do
[_, "android", hash] -> [%{platform: :android, hash: hash, path: path}]
[_, "android-arm32", hash] -> [%{platform: :android_arm32, hash: hash, path: path}]
[_, "ios-sim", hash] -> [%{platform: :ios_sim, hash: hash, path: path}]
[_, "ios-device", hash] -> [%{platform: :ios_device, hash: hash, path: path}]
_ -> []
end
end
@doc """
Fingerprint a single tarball directory. Returns the versions
extracted from disk.
"""
@spec fingerprint_tarball(Path.t()) :: tarball_versions()
def fingerprint_tarball(tarball_path) do
%{
erts: extract_erts_version(tarball_path),
elixir: extract_elixir_version(tarball_path),
openssl: extract_openssl_version(tarball_path),
exqlite_beam: extract_exqlite_version(tarball_path)
}
end
@doc """
Fingerprint the SQLite version compiled into exqlite from a
project's `deps/exqlite/c_src/sqlite3.c`. SQLite is bundled per-app
via the exqlite Hex package, not via the OTP tarball, so it's
scanned at the project level.
"""
@spec fingerprint_sqlite(Path.t()) :: {:ok, String.t()} | {:error, :not_found | :unparseable}
def fingerprint_sqlite(project_root) do
path = Path.join([project_root, "deps", "exqlite", "c_src", "sqlite3.c"])
cond do
not File.exists?(path) ->
{:error, :not_found}
true ->
# SQLite source files are tens of MB; only read the head where
# the version macro lives.
case File.open(path, [:read, :utf8], &read_sqlite_version/1) do
{:ok, {:ok, version}} -> {:ok, version}
{:ok, {:error, reason}} -> {:error, reason}
{:error, _} -> {:error, :unparseable}
end
end
end
defp read_sqlite_version(io) do
# Linear scan — the macro is in the first ~200 lines of sqlite3.c
# historically. Bail after 5000 lines as a safety net.
Enum.reduce_while(1..5000, {:error, :unparseable}, fn _, _acc ->
case IO.read(io, :line) do
:eof ->
{:halt, {:error, :unparseable}}
{:error, _} = e ->
{:halt, e}
line ->
case Regex.run(Regex.compile!(~S<^#define\s+SQLITE_VERSION\s+"([^"]+)">), line) do
[_, version] -> {:halt, {:ok, version}}
nil -> {:cont, {:error, :unparseable}}
end
end
end)
end
defp extract_erts_version(tarball_path) do
tarball_path
|> Path.join("erts-*")
|> Path.wildcard()
|> List.first()
|> case do
nil -> nil
path -> path |> Path.basename() |> String.replace_prefix("erts-", "")
end
end
defp extract_elixir_version(tarball_path) do
app_file = Path.join([tarball_path, "lib", "elixir", "ebin", "elixir.app"])
case File.read(app_file) do
{:ok, content} ->
case Regex.run(Regex.compile!(~S<\{vsn,\s*"([^"]+)"\}>), content) do
[_, version] -> version
nil -> nil
end
{:error, _} ->
nil
end
end
defp extract_exqlite_version(tarball_path) do
tarball_path
|> Path.join(["lib", "/", "exqlite-*"])
|> Path.wildcard()
|> List.first()
|> case do
nil -> nil
path -> path |> Path.basename() |> String.replace_prefix("exqlite-", "")
end
end
defp extract_openssl_version(tarball_path) do
libcrypto =
[tarball_path, "erts-*", "lib", "libcrypto.a"]
|> Path.join()
|> Path.wildcard()
|> List.first()
cond do
libcrypto == nil -> nil
not File.exists?(libcrypto) -> nil
true -> scan_openssl_version_string(libcrypto)
end
end
# OpenSSL embeds its version banner in libcrypto.a's .rodata as
#
# OpenSSL <version> <DD Mon YYYY>\0
#
# e.g. "OpenSSL 3.4.0 22 Oct 2024". libcrypto also contains lots of
# other strings starting with "OpenSSL " ("OpenSSL default", "OpenSSL
# DH Method", etc.), so we have to scan every match and pick the one
# whose second token looks like a version. No `strings` binary needed.
defp scan_openssl_version_string(path) do
case File.read(path) do
{:ok, content} -> scan_for_version_banner(content)
_ -> nil
end
end
defp scan_for_version_banner(content) do
content
|> :binary.matches("OpenSSL ")
|> Enum.find_value(&match_version_at(content, &1))
end
defp match_version_at(content, {pos, _len}) do
blob =
content
|> :binary.part(pos, min(64, byte_size(content) - pos))
|> :binary.split(<<0>>)
|> List.first()
case Regex.run(Regex.compile!(~S<^OpenSSL\s+(\d+\.\d+\.\d+[a-z]?)\b>), blob) do
[_, version] -> version
_ -> nil
end
end
end