lib/mix/tasks/sbom.cyclonedx.ex

defmodule Mix.Tasks.Sbom.Cyclonedx do
  @shortdoc "Generates CycloneDX SBoM"

  use Mix.Task
  import Mix.Generator

  @default_path "bom.xml"

  @moduledoc """
  Generates a Software Bill-of-Materials (SBoM) in CycloneDX format.

  ## Options

    * `--output` (`-o`): the full path to the SBoM output file (default:
      #{@default_path})
    * `--force` (`-f`): overwrite existing files without prompting for
      confirmation
    * `--dev` (`-d`): include dependencies for non-production environments
      (including `dev`, `test` or `docs`); by default only dependencies for
      MIX_ENV=prod are returned
    * `--recurse` (`-r`): in an umbrella project, generate individual output
      files for each application, rather than a single file for the entire
      project
    * `--schema` (`-s`): schema version to be used, defaults to "1.2".

  """

  @doc false
  @impl Mix.Task
  def run(all_args) do
    {opts, _args} =
      OptionParser.parse!(
        all_args,
        aliases: [o: :output, f: :force, d: :dev, r: :recurse, s: :schema],
        strict: [
          output: :string,
          force: :boolean,
          dev: :boolean,
          recurse: :boolean,
          schema: :string
        ]
      )

    output_path = opts[:output] || @default_path
    valiate_schema(opts)

    environment = (!opts[:dev] && :prod) || nil

    apps = Mix.Project.apps_paths()

    if opts[:recurse] && apps do
      Enum.each(apps, &generate_bom(&1, output_path, environment, opts[:force]))
    else
      generate_bom(output_path, environment, opts)
    end
  end

  defp generate_bom(output_path, environment, opts) do
    case SBoM.components_for_project(environment) do
      {:ok, components} ->
        xml = SBoM.CycloneDX.bom(components, opts)
        create_file(output_path, xml, force: opts[:force])

      {:error, :unresolved_dependency} ->
        dependency_error()
    end
  end

  defp generate_bom({app, path}, output_path, environment, force) do
    Mix.Project.in_project(app, path, fn _module ->
      generate_bom(output_path, environment, force)
    end)
  end

  defp dependency_error do
    shell = Mix.shell()
    shell.error("Unchecked dependencies; please run `mix deps.get`")
    Mix.raise("Can't continue due to errors on dependencies")
  end

  defp valiate_schema(opts) do
    schema_versions = ["1.2", "1.1"]

    if opts[:schema] && opts[:schema] not in schema_versions do
      shell = Mix.shell()

      shell.error(
        "invalid cyclonedx schema version, available versions are #{
          schema_versions |> Enum.join(", ")
        }"
      )

      Mix.raise("Give correct cyclonedx schema version to continue.")
    end
  end
end