Current section

Files

Jump to
mob_dev lib mob_dev security_scan layers bundled_runtime.ex
Raw

lib/mob_dev/security_scan/layers/bundled_runtime.ex

defmodule MobDev.SecurityScan.Layers.BundledRuntime do
@moduledoc """
Audits the OpenSSL, ERTS, Elixir, exqlite, and SQLite versions
baked into Mob's pre-built OTP tarballs and into the project's
`deps/exqlite/c_src/sqlite3.c`.
## Why this layer exists
Generic dependency scanners (`mix_audit`, `osv-scanner`,
Aqua/Snyk/Trivy) all assume *dynamic* linking — they look at lockfiles
and don't see versions baked into static archives. Mob ships
`libcrypto.a` (OpenSSL), `libbeam.a` (ERTS), the entire SQLite
amalgamation, and a frozen Elixir stdlib *inside* the OTP tarball
that gets copied into every app binary. Nothing in your `mix.lock`
reveals these versions.
This layer looks inside.
## What it does
1. **Fingerprint** — locate cached tarballs at `~/.mob/cache/otp-*-{hash}/`,
extract real versions of OpenSSL, ERTS, Elixir, and the bundled
exqlite BEAMs.
2. **Drift detection** — compare fingerprints against the
`BundledVersions` manifest. Any mismatch is a `:high` finding —
it means the manifest is lying about what shipped, which is the
exact failure mode the manifest exists to prevent.
3. **SQLite fingerprint** — read `deps/exqlite/c_src/sqlite3.c` from
the scanned project and extract `SQLITE_VERSION`. SQLite isn't
in the OTP tarball; it's compiled per-app via exqlite.
4. **Version transparency** — emit informational notes documenting
every detected version with a pointer to its upstream advisory
page. We don't pretend to do live CVE lookups for OpenSSL/SQLite
— there's no reliable machine-readable feed at this writing
(OpenSSL retired its JSON feed; OSV.dev doesn't cover native libs).
The notes give the user everything they need to verify manually.
Hex-ecosystem CVEs (including exqlite the BEAM package) are handled
by `hex_deps`, not duplicated here.
"""
@behaviour MobDev.SecurityScan.Layer
alias MobDev.SecurityScan.{BundledVersions, Finding, LayerResult}
alias MobDev.SecurityScan.BundledRuntime.Fingerprint
@impl true
def name, do: :bundled_runtime
@impl true
def run(opts) do
project_root = Keyword.get(opts, :project_root, File.cwd!())
cache_dir_opts = if dir = Keyword.get(opts, :cache_dir), do: [cache_dir: dir], else: []
tarballs = Fingerprint.locate_cached_tarballs(cache_dir_opts)
manifest = safe_load_manifest()
{drift_findings, version_notes, status, error} =
analyze(tarballs, manifest, project_root)
%LayerResult{
name: :bundled_runtime,
status: status,
findings: drift_findings,
tools_used: ["BundledVersions manifest", "fingerprint"],
notes: version_notes,
error: error
}
end
defp safe_load_manifest do
{:ok, BundledVersions.load()}
rescue
e -> {:error, Exception.message(e)}
end
defp analyze([], _manifest, project_root) do
sqlite_notes = sqlite_notes(project_root)
notes =
[
"no cached OTP tarballs found at ~/.mob/cache/",
"run `mix mob.deploy --native` from a Mob app to populate the cache"
] ++ sqlite_notes
{[], notes, :not_applicable, nil}
end
defp analyze(_tarballs, {:error, reason}, _project_root) do
{[], [], :error, "bundled-versions manifest failed to load: #{reason}"}
end
defp analyze(tarballs, {:ok, manifest}, project_root) do
{drift_findings, per_tarball_notes} = check_tarballs(tarballs, manifest)
sqlite_notes = sqlite_notes(project_root)
upstream_notes = upstream_pointer_notes(manifest)
notes =
["scanned #{length(tarballs)} cached OTP tarball(s)"] ++
per_tarball_notes ++ upstream_notes ++ sqlite_notes
{drift_findings, notes, :ok, nil}
end
defp check_tarballs(tarballs, manifest) do
Enum.reduce(tarballs, {[], []}, fn tb, {findings_acc, notes_acc} ->
versions = Fingerprint.fingerprint_tarball(tb.path)
{findings, note} = compare_to_manifest(tb, versions, manifest)
{findings_acc ++ findings, notes_acc ++ [note]}
end)
end
defp compare_to_manifest(tb, versions, manifest) do
case Map.fetch(manifest.bundles, tb.hash) do
{:ok, bundle} ->
check_bundle(tb, versions, bundle)
:error ->
# Tarball on disk has an unknown hash — could be from an
# older Mob release. Inform but don't error.
{[],
" · #{tb.platform} (#{tb.hash}): hash not in manifest — older or unpublished tarball"}
end
end
defp expected_for(bundle, platform, key) do
overrides = bundle |> Map.get(:per_platform, %{}) |> Map.get(platform, %{})
if Map.has_key?(overrides, key) do
Map.fetch!(overrides, key)
else
Map.get(bundle, key)
end
end
defp check_bundle(tb, versions, bundle) do
fields = [
{:erts, "ERTS"},
{:elixir, "Elixir"},
{:openssl, "OpenSSL"},
{:exqlite_beam, "exqlite (BEAM)"}
]
drifts =
Enum.flat_map(fields, fn {key, label} ->
actual = Map.get(versions, key)
expected = expected_for(bundle, tb.platform, key)
compare_field(tb, label, key, expected, actual)
end)
summary =
" · #{tb.platform} (#{tb.hash}): " <>
"ERTS #{versions.erts || "?"}, " <>
"Elixir #{versions.elixir || "?"}, " <>
"OpenSSL #{versions.openssl || "?"}, " <>
"exqlite #{versions.exqlite_beam || "n/a"}" <>
if drifts == [], do: " ✓", else: " ✗ DRIFT (#{length(drifts)})"
{drifts, summary}
end
# Both expected and actual nil → manifest declares "this platform
# doesn't ship this artifact" and the binary agrees. Not drift.
defp compare_field(_tb, _label, _key, nil, nil), do: []
defp compare_field(_tb, _label, _key, expected, actual) when expected == actual, do: []
defp compare_field(tb, label, key, expected, nil) do
[
%Finding{
id: "MOB-DRIFT-#{tb.platform}-#{key}",
severity: :high,
package: "mob/otp-tarball",
version: "#{tb.platform}@#{tb.hash}",
title: "Manifest lists #{label} #{expected} but binary has no detectable version",
description:
"Fingerprinting the tarball at #{tb.path} could not extract a #{label} version. " <>
"Either the binary was built without the expected metadata, or fingerprinting needs to be updated.",
url: "https://github.com/genericjam/mob_dev/blob/main/priv/security/bundled_versions.exs",
source: :bundled_runtime,
layer: :bundled_runtime
}
]
end
defp compare_field(tb, label, key, expected, actual) do
[
%Finding{
id: "MOB-DRIFT-#{tb.platform}-#{key}",
severity: :high,
package: "mob/otp-tarball",
version: "#{tb.platform}@#{tb.hash}",
fixed_in: nil,
title: "Bundled-versions drift: #{label} manifest=#{expected} binary=#{actual}",
description:
"Manifest at priv/security/bundled_versions.exs claims #{label} #{expected} " <>
"but the actual binary contains #{actual}. " <>
"Update the manifest to match the binary, or rebuild the tarball.",
url: "https://github.com/genericjam/mob_dev/blob/main/priv/security/bundled_versions.exs",
source: :bundled_runtime,
layer: :bundled_runtime
}
]
end
defp upstream_pointer_notes(manifest) do
bundle = Map.get(manifest.bundles, manifest.active_hash, %{})
[
"── version pointers (verify advisories upstream) ──",
" OpenSSL #{bundle[:openssl] || "?"} (#{bundle[:openssl_release_date] || "?"}) — https://openssl-library.org/news/vulnerabilities/",
" Erlang/OTP #{bundle[:otp_release] || "?"} (ERTS #{bundle[:erts] || "?"}) — https://github.com/erlef/security-wg/tree/main/advisories",
" Elixir #{bundle[:elixir] || "?"} — https://github.com/elixir-lang/elixir/security/advisories",
" exqlite (BEAM) #{bundle[:exqlite_beam] || "?"} — covered by :hex_deps layer"
]
end
defp sqlite_notes(project_root) do
case Fingerprint.fingerprint_sqlite(project_root) do
{:ok, version} ->
[
" SQLite #{version} (from #{Path.relative_to_cwd(Path.join([project_root, "deps/exqlite/c_src/sqlite3.c"]))}) — https://www.sqlite.org/cves.html"
]
{:error, :not_found} ->
[
" SQLite: no deps/exqlite/c_src/sqlite3.c — exqlite not in this project's deps tree"
]
{:error, :unparseable} ->
[
" SQLite: deps/exqlite/c_src/sqlite3.c present but version macro could not be parsed"
]
end
end
end