MobDev.Plugin.Audit (mob_dev v0.5.17)

Copy Markdown View Source

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.

Summary

Functions

Audits a single C source file in isolation. Public for testing.

Audits a single Elixir source file in isolation. Public for testing.

Audits a single plugin checked out at plugin_dir.

Computes the exit code for one or more reports.

Tally findings into %{high: n, medium: n, low: n}. Public for testing.

Types

finding()

@type finding() :: %{
  severity: severity(),
  rule: atom(),
  plugin: atom() | nil,
  file: String.t(),
  line: pos_integer() | nil,
  snippet: String.t(),
  hint: String.t()
}

report()

@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()
}

severity()

@type severity() :: :high | :medium | :low

Functions

audit_c_file(path, plugin_dir, plugin_name)

@spec audit_c_file(Path.t(), Path.t(), atom() | nil) :: [finding()]

Audits a single C source file in isolation. Public for testing.

audit_elixir_file(path, plugin_dir, plugin_name)

@spec audit_elixir_file(Path.t(), Path.t(), atom() | nil) :: [finding()]

Audits a single Elixir source file in isolation. Public for testing.

audit_plugin(plugin_dir, manifest)

@spec audit_plugin(Path.t(), map() | nil) :: report()

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 report/0 map.

exit_code(reports, accept_medium? \\ false)

@spec exit_code([report()], boolean()) :: 0 | 1 | 2

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.

tally(findings)

@spec tally([finding()]) :: %{
  high: non_neg_integer(),
  medium: non_neg_integer(),
  low: non_neg_integer()
}

Tally findings into %{high: n, medium: n, low: n}. Public for testing.