lib/mix/tasks/sbom.cyclonedx.ex

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

  use Mix.Task
  import Mix.Generator

  @schema_versions ["1.6", "1.5", "1.4", "1.3", "1.2", "1.1"]

  @default_path "bom.xml"
  @default_path_json "bom.json"
  @default_schema "1.6"
  @default_classification "application"

  @default_opts [
    schema: @default_schema,
    classification: @default_classification
  ]

  @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
      "#{@default_schema}"
    * `--format` (`-t`): output format: xml or json; defaults to "xml", unless
      the output path ends with ".json"
    * `--classification` (`-c`): the project classification, e.g. "application",
      "library", "framework"; defaults to "#{@default_classification}"

  """

  @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,
          t: :format,
          c: :classification
        ],
        strict: [
          output: :string,
          force: :boolean,
          dev: :boolean,
          recurse: :boolean,
          schema: :string,
          format: :string,
          classification: :string
        ]
      )

    opts =
      @default_opts
      |> Keyword.merge(opts)
      |> update_output_path_and_format!()

    validate_schema!(opts[:schema])

    output_path = opts[:output]
    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
    classification = opts[:classification]

    case SBoM.components_for_project(classification, 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 update_output_path_and_format!(opts) do
    {output, format} =
      case {opts[:output], opts[:format]} do
        {nil, nil} ->
          {@default_path, format_from_path(@default_path)}

        {output, nil} ->
          {output, format_from_path(output)}

        {nil, "xml"} ->
          {@default_path, "xml"}

        {nil, "json"} ->
          {@default_path_json, "json"}

        {output, format} when format in ["xml", "json"] ->
          {output, format}

        {_, format} ->
          Mix.raise("Unsupported output format: #{format}")
      end

    Keyword.merge(opts, output: output, format: format)
  end

  defp format_from_path(path) do
    case Path.extname(path) do
      ".json" -> "json"
      _ -> "xml"
    end
  end

  defp validate_schema!(schema) do
    if 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