Packages

A collection of opinionated Credo checks aimed at improving code quality and catching common mistakes in Elixir, Oban, and LiveView

Current section

Files

Jump to
jump_credo_checks lib jump credo_checks forbidden_function.ex
Raw

lib/jump/credo_checks/forbidden_function.ex

defmodule Jump.CredoChecks.ForbiddenFunction do
@moduledoc """
Forbids calls to specific functions.
Similar to `Credo.Check.Warning.ForbiddenModule`, but for specific functions
within a module rather than entire modules.
## Configuration
Configure with a list of `{module, function, message}` tuples:
{Jump.CredoChecks.ForbiddenFunction,
functions: [
{:erlang, :binary_to_term, "Use Plug.Crypto.non_executable_binary_to_term/2 instead."}
]}
"""
use Credo.Check,
base_priority: :high,
category: :warning,
param_defaults: [
functions: []
],
explanations: [
check: """
Some functions may be hazardous if used directly. Use this check to
forbid specific functions while allowing the module itself.
For example, `:erlang.binary_to_term/1` is vulnerable to arbitrary code
execution exploits when deserializing untrusted data. Use
`Plug.Crypto.non_executable_binary_to_term/2` instead, which disallows
anonymous functions in the deserialized term.
""",
params: [
functions: """
List of `{module, function, message}` tuples specifying forbidden functions.
Example:
functions: [
{:erlang, :binary_to_term, "Use Plug.Crypto.non_executable_binary_to_term/2 instead."}
]
"""
]
]
@doc false
@impl Credo.Check
def run(%SourceFile{} = source_file, params \\ []) do
issue_meta = IssueMeta.for(source_file, params)
functions = Params.get(params, :functions, __MODULE__)
forbidden_map = build_forbidden_map(functions)
Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta, forbidden_map))
end
defp build_forbidden_map(functions) do
{qualified, bare} =
Enum.split_with(functions, fn
{module, function, _message} when is_atom(module) and is_atom(function) -> true
_ -> false
end)
qualified_map =
Map.new(qualified, fn {module, function, message} -> {{module, function}, message} end)
bare_map =
Map.new(bare, fn {function, arity, message} -> {{function, arity}, message} end)
{qualified_map, bare_map}
end
# Handle calls to erlang modules like :erlang.binary_to_term(x)
defp traverse({{:., meta, [module, function]}, _call_meta, _args} = ast, issues, issue_meta, {qualified_map, _})
when is_atom(module) and is_atom(function) do
case Map.get(qualified_map, {module, function}) do
nil ->
{ast, issues}
message ->
trigger = "#{inspect(module)}.#{function}"
{ast, [create_issue(issue_meta, meta[:line], trigger, message) | issues]}
end
end
# Handle calls to Elixir modules like Module.function(x)
# Guard ensures all module_parts are atoms (excludes __MODULE__ etc.)
defp traverse(
{{:., meta, [{:__aliases__, _, module_parts}, function]}, _call_meta, _args} = ast,
issues,
issue_meta,
{qualified_map, _}
)
when is_atom(function) and is_list(module_parts) do
if Enum.all?(module_parts, &is_atom/1) do
module = Module.concat(module_parts)
case Map.get(qualified_map, {module, function}) do
nil ->
{ast, issues}
message ->
trigger = "#{inspect(module)}.#{function}"
{ast, [create_issue(issue_meta, meta[:line], trigger, message) | issues]}
end
else
# module_parts contains non-atoms like {:__MODULE__, _, _}, skip
{ast, issues}
end
end
# Handle unqualified (bare) function calls like function_exported?(mod, :func, 1)
defp traverse({function, meta, args} = ast, issues, issue_meta, {_, bare_map})
when is_atom(function) and is_list(args) do
arity = length(args)
case Map.get(bare_map, {function, arity}) do
nil ->
{ast, issues}
message ->
trigger = "#{function}/#{arity}"
{ast, [create_issue(issue_meta, meta[:line], trigger, message) | issues]}
end
end
defp traverse(ast, issues, _issue_meta, _forbidden_map), do: {ast, issues}
defp create_issue(issue_meta, line_no, trigger, message) do
format_issue(
issue_meta,
message: "#{trigger} is forbidden: #{message}",
trigger: trigger,
line_no: line_no
)
end
end