defmodule Mix.Tasks.Safe do
use Mix.Task
@shortdoc "SAFE security vulnerability scanner"
@moduledoc """
Runs SAFE security vulnerability scans on your Elixir/Mix project.
## Usage
mix safe <subcommand> [options]
## Subcommands
* `fingerprint` — run the SAFE fingerprint phase
* `analyse` — run the SAFE analysis phase
* `download` — download the SAFE binary only
* `version` — print plugin and binary versions
* `help` — print this help
## Examples
mix safe fingerprint
mix safe analyse
mix safe download
mix safe version
## Debug logging
Detailed debug output is written to `_build/safe/safe.log`. The terminal
shows only info-level messages.
"""
require Logger
@version Mix.Project.config()[:version]
@impl Mix.Task
def run(args) do
{_opts, rest, _} = OptionParser.parse(args, strict: [])
project_dir = Mix.Project.project_file() |> Path.dirname() |> Path.expand()
setup_file_logger(project_dir)
Logger.debug("project_dir=#{project_dir}")
case rest do
["fingerprint"] -> handle_fingerprint(project_dir)
["analyse"] -> handle_analyse(project_dir)
["download"] -> handle_download(project_dir)
["version"] -> handle_version(project_dir)
["help"] -> handle_help()
[] -> error_and_exit("No subcommand specified. Run `mix safe help` for usage.", 1)
[other | _] -> error_and_exit("Unrecognised subcommand: #{other}. Run `mix safe help`.", 1)
end
end
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
defp handle_fingerprint(project_dir) do
Logger.debug("running fingerprint")
with :ok <- ensure_binary(project_dir),
{:ok, config_spec} <- resolve_config_for_fingerprint(project_dir) do
Safe.IO.print_status("* running SAFE fingerprint")
case Safe.Shell.run_safe("fingerprint", project_dir, config_spec) do
:ok ->
Safe.IO.print_status("* SAFE fingerprint complete")
{:error, {:fingerprint, 2}} ->
Safe.IO.print_status("* SAFE fingerprint complete - vulnerabilities found.")
exit_with(2)
{:error, {:fingerprint, n}} ->
handle_error({:fingerprint, n})
end
else
{:error, reason} -> handle_error(reason)
end
end
defp handle_analyse(project_dir) do
Logger.debug("running analyse")
with :ok <- ensure_binary(project_dir),
{:ok, config_spec} <- resolve_config_for_analyse(project_dir) do
Safe.IO.print_status("* running SAFE analysis")
case Safe.Shell.run_safe("analyse", project_dir, config_spec) do
:ok ->
Safe.IO.print_status("* SAFE analysis complete - no vulnerabilities found")
{:error, {:analyse, 2}} ->
Safe.IO.print_status("* SAFE analysis complete - vulnerabilities found.")
exit_with(2)
{:error, {:analyse, n}} ->
handle_error({:analyse, n})
end
else
{:error, reason} -> handle_error(reason)
end
end
defp handle_download(project_dir) do
Logger.debug("running download")
Safe.IO.print_status("* checking for SAFE binary")
case Safe.Binary.ensure_binary_available(project_dir) do
:ok -> Safe.IO.print_status("* SAFE binary ready")
{:error, reason} -> handle_error(reason)
end
end
defp handle_version(project_dir) do
Safe.IO.print_info("* safe-mix-plugin version: #{@version}")
bin_path = Safe.Binary.binary_path(project_dir)
if File.exists?(bin_path) do
dir = Path.dirname(bin_path)
case System.cmd(bin_path, ["version"],
cd: dir,
stderr_to_stdout: true,
into: IO.stream(:stdio, :line)
) do
{_, 0} -> :ok
{_, n} -> handle_error({:version_failed, n})
end
else
Safe.IO.print_info("* SAFE binary not yet downloaded")
end
end
defp handle_help do
Mix.Task.moduledoc(__MODULE__) |> Safe.IO.print_info()
end
# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------
defp resolve_config_for_fingerprint(project_dir) do
config_path = Safe.Config.config_path(project_dir)
if File.exists?(config_path) do
Safe.IO.print_status("* Using config from .safe/config.json")
{:ok, {:config_path, config_path}}
else
safe_fingerprint_no_config(project_dir)
end
end
defp safe_fingerprint_no_config(project_dir) do
Safe.IO.print_status("* checking your project's structure")
with {:ok, apps} <- discover_apps(),
{:ok, config_json} <- Safe.Config.make_config(project_dir) do
Safe.IO.print_info(
"* Discovered #{length(apps)} app(s): #{inspect(Enum.map(apps, & &1.name))}"
)
Safe.IO.print_info(config_json)
if Safe.IO.bool_prompt("Would you like to proceed with this configuration?") do
{:ok, {:config_json, config_json}}
else
case Safe.Config.write_config(project_dir, config_json) do
:ok ->
Safe.IO.print_info(
"Config saved to .safe/config.json. Edit it and re-run `mix safe fingerprint`."
)
exit_with(0)
{:error, reason} ->
{:error, {:config_write_error, reason}}
end
end
end
end
defp resolve_config_for_analyse(project_dir) do
config_path = Safe.Config.config_path(project_dir)
if File.exists?(config_path) do
Safe.IO.print_info("* Using config from .safe/config.json")
{:ok, {:config_path, config_path}}
else
{:ok, json} = Safe.Config.make_config(project_dir)
{:ok, {:config_json, json}}
end
end
defp discover_apps do
if Mix.Project.umbrella?() do
apps =
Mix.Project.apps_paths()
|> Enum.map(fn {name, _path} -> %{name: name} end)
{:ok, apps}
else
app_name = Mix.Project.config()[:app]
{:ok, [%{name: app_name}]}
end
end
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
defp ensure_binary(project_dir) do
case Safe.Binary.ensure_binary_available(project_dir) do
:ok -> :ok
{:error, reason} -> {:error, reason}
end
end
@spec handle_error(term()) :: no_return()
defp handle_error(:unsupported_platform) do
error_and_exit("Unsupported platform. SAFE supports Linux and macOS only.", 1)
end
defp handle_error(:unsupported_arch) do
error_and_exit("Unsupported CPU architecture. SAFE supports x86_64 and ARM64.", 1)
end
defp handle_error({:no_checksum_for_platform, platform}) do
error_and_exit("No checksum available for platform #{platform}", 1)
end
defp handle_error(:no_compatible_version) do
error_and_exit("No compatible SAFE version found (requires ~> 1.5.0)", 1)
end
defp handle_error({:locked_version_not_found, version}) do
error_and_exit(
"Version #{version} from safe.lock not found in manifest. Delete safe.lock and re-run.",
1
)
end
defp handle_error({:checksum_mismatch, _}) do
error_and_exit("Downloaded file is corrupted (checksum mismatch). Please try again.", 1)
end
defp handle_error({:download_failed, url, reason}) do
error_and_exit("Failed to download SAFE from #{url}: #{inspect(reason)}", 1)
end
defp handle_error({:http_error, 404}) do
error_and_exit("SAFE version not found. Check version and try again.", 1)
end
defp handle_error({:http_error, code}) do
error_and_exit("HTTP error #{code} while contacting SAFE release server.", 1)
end
defp handle_error({:config_read_error, reason}) do
error_and_exit("Failed to read .safe/config.json: #{inspect(reason)}", 1)
end
defp handle_error({:fingerprint, 2}) do
error_and_exit("SAFE analysis complete - vulnerabilities found.", 2)
end
defp handle_error({:fingerprint, n}) do
error_and_exit("SAFE fingerprint failed with exit code #{n}.", 1)
end
defp handle_error({:analyse, 2}) do
error_and_exit("SAFE analysis complete - vulnerabilities found.", 2)
end
defp handle_error({:analyse, n}) do
error_and_exit("SAFE analysis failed with exit code #{n}.", 1)
end
defp handle_error({:version_failed, n}) do
error_and_exit("SAFE binary `version` command failed with exit code #{n}.", 1)
end
defp handle_error(:binary_not_found_after_extract) do
error_and_exit(
"SAFE binary not found after extraction. The tarball layout may have changed.",
1
)
end
defp handle_error({:untar_failed, reason}) do
error_and_exit("Failed to extract SAFE tarball: #{inspect(reason)}", 1)
end
defp handle_error(reason) do
error_and_exit("Unexpected error: #{inspect(reason)}", 1)
end
@spec error_and_exit(String.t(), non_neg_integer()) :: no_return()
defp error_and_exit(message, code) do
Safe.IO.print_error(message)
exit_with(code)
end
# ---------------------------------------------------------------------------
# Logger file setup
# ---------------------------------------------------------------------------
defp setup_file_logger(project_dir) do
log_dir = Path.join([project_dir, "_build", "safe"])
File.mkdir_p!(log_dir)
log_file = Path.join(log_dir, "safe.log") |> String.to_charlist()
case :logger.add_handler(:safe_file_handler, :logger_std_h, %{
level: :debug,
config: %{type: :file, file: log_file}
}) do
:ok ->
:ok
{:error, {:already_exist, _}} ->
:ok
{:error, reason} ->
Logger.warning("Could not set up SAFE file logger: #{inspect(reason)}")
end
:logger.set_handler_config(:default, :level, :info)
end
@spec exit_with(non_neg_integer()) :: no_return()
defp exit_with(code), do: exit({:shutdown, code})
end