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/plugin/audit.ex
defmodule MobDev.Plugin.Audit do
@moduledoc """
Static-analysis pass over a plugin's source tree.
Behind `mix mob.audit_plugins` (see `MOB_PLUGIN_SECURITY.md`). Walks the
plugin's Elixir sources (`lib/**/*.ex{,s}`) with an AST scanner and its C
NIF sources (`priv/native/**/*.{c,h}`) with a tighter regex pass, flagging
patterns Mob considers risky:
- `Code.eval_string/1,2,3` and `Code.compile_string/1,2`
— the prime arbitrary-code-execution vector. Severity `:high`.
- `String.to_atom/1` with a non-literal argument — atom-exhaustion risk.
A literal `String.to_atom("foo")` is fine; flagged only when the argument
is a variable or expression. Severity `:medium`.
- `:erlang.binary_to_term/1` — unbounded deserialization. The arity-2 form
`:erlang.binary_to_term(bin, [:safe])` does not fire (caller has opted
into the bounded variant). Severity `:high`.
- `Application.put_env/3,4` targeting `:mob` — would let a plugin silently
retarget plugin activations, host_config keys, etc. `get_env` is fine.
Severity `:medium`.
- File / network I/O escape hatches outside the plugin's own `priv/`:
`File.write{,!}/1,2`, `File.rm_rf{,!}/1`, `File.cp{,!}/2`, `:os.cmd/1`,
`System.cmd/2,3`, `Path.expand/1` with a `~` literal. We don't try to
prove what they touch — flag and let the plugin author either remove or
justify. Severity `:medium`.
- In C NIF sources: calls to `system(3)`, `popen(3)`, `execve(2)`, and raw
`socket(2)` creation. Regex over comment-stripped text. Severities
`:medium` (`socket`) and `:high` (`system`/`popen`/`execve`).
Swift / Kotlin sources are out of scope for this commit — the spec
(`MOB_PLUGIN_SECURITY.md`) calls for proper parsers there, and the task
surfaces a "not yet audited" summary line instead of guessing with regex.
All checks are pure given file inputs; the only I/O is reading source
files off disk.
"""
@type severity :: :high | :medium | :low
@type finding :: %{
severity: severity(),
rule: atom(),
plugin: atom() | nil,
file: String.t(),
line: pos_integer() | nil,
snippet: String.t(),
hint: String.t()
}
@type report :: %{
plugin: atom() | nil,
findings: [finding()],
summary: %{high: non_neg_integer(), medium: non_neg_integer(), low: non_neg_integer()},
kotlin_or_swift_skipped: boolean()
}
@elixir_glob "**/*.{ex,exs}"
@c_glob "**/*.{c,h}"
@doc """
Audits a single plugin checked out at `plugin_dir`.
`manifest` may be `nil` (a tier-0 plugin); only `:name` is read from it for
attribution in the resulting findings. Returns a `t:report/0` map.
"""
@spec audit_plugin(Path.t(), map() | nil) :: report()
def audit_plugin(plugin_dir, manifest) do
name = (manifest && Map.get(manifest, :name)) || nil
elixir_findings =
plugin_dir
|> elixir_sources()
|> Enum.flat_map(&audit_elixir_file(&1, plugin_dir, name))
c_findings =
plugin_dir
|> c_sources()
|> Enum.flat_map(&audit_c_file(&1, plugin_dir, name))
findings =
(elixir_findings ++ c_findings)
|> Enum.sort_by(fn f -> {severity_order(f.severity), f.file, f.line || 0} end)
%{
plugin: name,
findings: findings,
summary: tally(findings),
kotlin_or_swift_skipped: has_kotlin_or_swift?(plugin_dir)
}
end
@doc """
Audits a single Elixir source file in isolation. Public for testing.
"""
@spec audit_elixir_file(Path.t(), Path.t(), atom() | nil) :: [finding()]
def audit_elixir_file(path, plugin_dir, plugin_name) do
rel = relative_to(path, plugin_dir)
case read_quoted(path) do
{:ok, ast} ->
scan_elixir_ast(ast, rel, plugin_name)
{:error, reason} ->
[
%{
severity: :low,
rule: :unparseable_source,
plugin: plugin_name,
file: rel,
line: nil,
snippet: "",
hint: "could not parse as Elixir: #{reason}"
}
]
end
end
@doc """
Audits a single C source file in isolation. Public for testing.
"""
@spec audit_c_file(Path.t(), Path.t(), atom() | nil) :: [finding()]
def audit_c_file(path, plugin_dir, plugin_name) do
rel = relative_to(path, plugin_dir)
case File.read(path) do
{:ok, source} ->
source
|> strip_c_comments()
|> scan_c_source(rel, plugin_name)
{:error, reason} ->
[
%{
severity: :low,
rule: :unreadable_source,
plugin: plugin_name,
file: rel,
line: nil,
snippet: "",
hint: "could not read: #{:file.format_error(reason)}"
}
]
end
end
@doc """
Tally findings into `%{high: n, medium: n, low: n}`. Public for testing.
"""
@spec tally([finding()]) :: %{
high: non_neg_integer(),
medium: non_neg_integer(),
low: non_neg_integer()
}
def tally(findings) do
Enum.reduce(findings, %{high: 0, medium: 0, low: 0}, fn f, acc ->
Map.update!(acc, f.severity, &(&1 + 1))
end)
end
@doc """
Computes the exit code for one or more reports.
* 0 — every finding is `:low` (or there are none).
* 1 — at least one `:medium`, no `:high`.
* 2 — at least one `:high`.
When `accept_medium?` is true, mediums no longer count toward exit code 1
(highs still produce 2). Public for testing.
"""
@spec exit_code([report()], boolean()) :: 0 | 1 | 2
def exit_code(reports, accept_medium? \\ false) do
totals =
Enum.reduce(reports, %{high: 0, medium: 0, low: 0}, fn r, acc ->
Map.merge(acc, r.summary, fn _k, a, b -> a + b end)
end)
cond do
totals.high > 0 -> 2
not accept_medium? and totals.medium > 0 -> 1
true -> 0
end
end
# ── source discovery ──────────────────────────────────────────────────────
defp elixir_sources(plugin_dir) do
plugin_dir
|> Path.join("lib")
|> Path.join(@elixir_glob)
|> Path.wildcard()
end
defp c_sources(plugin_dir) do
plugin_dir
|> Path.join("priv/native")
|> Path.join(@c_glob)
|> Path.wildcard()
end
defp has_kotlin_or_swift?(plugin_dir) do
Path.wildcard(Path.join(plugin_dir, "priv/native/**/*.{kt,swift}")) != []
end
defp relative_to(path, base) do
path
|> Path.relative_to(base)
|> to_string()
end
# ── Elixir AST scanner ────────────────────────────────────────────────────
defp read_quoted(path) do
case File.read(path) do
{:ok, source} ->
try do
{:ok, Code.string_to_quoted!(source, columns: true, file: path)}
rescue
e -> {:error, Exception.message(e)}
end
{:error, reason} ->
{:error, :file.format_error(reason)}
end
end
defp scan_elixir_ast(ast, file, plugin) do
{_, acc} =
Macro.prewalk(ast, [], fn node, acc ->
new = check_elixir_node(node, file, plugin)
{node, new ++ acc}
end)
Enum.reverse(acc)
end
# Code.eval_string/1,2,3 and Code.compile_string/1,2 — :high
defp check_elixir_node({{:., _, [{:__aliases__, _, [:Code]}, fun]}, meta, args}, file, plugin)
when fun in [:eval_string, :compile_string] and is_list(args) do
[
finding(
:high,
:code_eval,
plugin,
file,
line(meta),
"Code.#{fun}(...)",
"Arbitrary code execution. Remove this call or justify it in the manifest."
)
]
end
# :erlang.binary_to_term/1 — :high (arity-2 with [:safe] is fine)
defp check_elixir_node({{:., _, [:erlang, :binary_to_term]}, meta, args}, file, plugin)
when length(args) == 1 do
[
finding(
:high,
:unsafe_deserialization,
plugin,
file,
line(meta),
":erlang.binary_to_term/1",
"Use :erlang.binary_to_term(bin, [:safe]) to bound deserialization."
)
]
end
# String.to_atom/1 with non-literal argument — :medium
defp check_elixir_node(
{{:., _, [{:__aliases__, _, [:String]}, :to_atom]}, meta, [arg]},
file,
plugin
) do
if literal_binary?(arg) do
[]
else
[
finding(
:medium,
:unbounded_atom,
plugin,
file,
line(meta),
"String.to_atom(<non-literal>)",
"Use String.to_existing_atom/1 or an explicit whitelist to avoid atom-table exhaustion."
)
]
end
end
# Application.put_env(:mob, ...) — :medium
defp check_elixir_node(
{{:., _, [{:__aliases__, _, [:Application]}, :put_env]}, meta, [app | _]} = _node,
file,
plugin
) do
if app == :mob do
[
finding(
:medium,
:mob_env_mutation,
plugin,
file,
line(meta),
"Application.put_env(:mob, ...)",
"A plugin should not mutate the :mob app's env; route changes through manifest declarations."
)
]
else
[]
end
end
# File.write/write!/rm_rf/rm_rf!/cp/cp! — :medium
defp check_elixir_node({{:., _, [{:__aliases__, _, [:File]}, fun]}, meta, _args}, file, plugin)
when fun in [:write, :write!, :rm_rf, :rm_rf!, :cp, :cp!] do
[
finding(
:medium,
:file_io,
plugin,
file,
line(meta),
"File.#{fun}(...)",
"File-system mutation outside the plugin's priv/. Remove or justify in the manifest."
)
]
end
# :os.cmd/1 — :medium
defp check_elixir_node({{:., _, [:os, :cmd]}, meta, _args}, file, plugin) do
[
finding(
:medium,
:process_spawn,
plugin,
file,
line(meta),
":os.cmd(...)",
"Shell-out from a plugin is highly suspect. Remove or justify."
)
]
end
# System.cmd/2,3 — :medium
defp check_elixir_node(
{{:., _, [{:__aliases__, _, [:System]}, :cmd]}, meta, _args},
file,
plugin
) do
[
finding(
:medium,
:process_spawn,
plugin,
file,
line(meta),
"System.cmd(...)",
"Process spawn from a plugin is highly suspect. Remove or justify."
)
]
end
# Path.expand("~"...) — :medium (home-directory escape)
defp check_elixir_node(
{{:., _, [{:__aliases__, _, [:Path]}, :expand]}, meta, [arg | _]},
file,
plugin
) do
if home_string?(arg) do
[
finding(
:medium,
:home_escape,
plugin,
file,
line(meta),
"Path.expand(\"~...\")",
"Reaching outside the app sandbox via $HOME. Remove or justify."
)
]
else
[]
end
end
defp check_elixir_node(_node, _file, _plugin), do: []
defp literal_binary?(arg) when is_binary(arg), do: true
defp literal_binary?(_), do: false
defp home_string?(s) when is_binary(s), do: String.starts_with?(s, "~")
defp home_string?(_), do: false
defp line(meta) when is_list(meta), do: Keyword.get(meta, :line)
defp line(_), do: nil
# ── C source scanner ──────────────────────────────────────────────────────
# Comment-strip + line-based scan. Regex on stripped source avoids matching
# the words inside `// system(...)` documentation comments — common in NIF
# stubs that explain why they *don't* call system(3).
defp scan_c_source(stripped, file, plugin) do
stripped
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.flat_map(fn {line_text, ln} -> check_c_line(line_text, ln, file, plugin) end)
end
@c_rules [
{:high, :process_spawn, ~r/\bsystem\s*\(/, "system(3) call",
"Process spawn from a NIF is highly suspect."},
{:high, :process_spawn, ~r/\bpopen\s*\(/, "popen(3) call",
"Process spawn from a NIF is highly suspect."},
{:high, :process_spawn, ~r/\bexecve\s*\(/, "execve(2) call",
"Process spawn from a NIF is highly suspect."},
{:medium, :raw_socket, ~r/\bsocket\s*\(/, "socket(2) call",
"Raw network access from a NIF should be declared and justified."}
]
defp check_c_line(line_text, ln, file, plugin) do
@c_rules
|> Enum.filter(fn {_sev, _rule, regex, _snippet, _hint} ->
Regex.match?(regex, line_text)
end)
|> Enum.map(fn {sev, rule, _regex, snippet, hint} ->
finding(sev, rule, plugin, file, ln, snippet, hint)
end)
end
# Strip `/* ... */` and `// ...` comments. Keeps line counts intact by
# replacing the comment body with spaces and preserving newlines.
defp strip_c_comments(source) do
source
|> strip_block_comments()
|> strip_line_comments()
end
defp strip_block_comments(source) do
Regex.replace(~r{/\*.*?\*/}s, source, fn match ->
match
|> String.graphemes()
|> Enum.map(fn
"\n" -> "\n"
_ -> " "
end)
|> Enum.join()
end)
end
defp strip_line_comments(source) do
Regex.replace(~r{//[^\n]*}, source, fn match ->
String.duplicate(" ", String.length(match))
end)
end
# ── helpers ───────────────────────────────────────────────────────────────
defp finding(severity, rule, plugin, file, line, snippet, hint) do
%{
severity: severity,
rule: rule,
plugin: plugin,
file: file,
line: line,
snippet: snippet,
hint: hint
}
end
defp severity_order(:high), do: 0
defp severity_order(:medium), do: 1
defp severity_order(:low), do: 2
end