lib/sbom.ex

defmodule SBoM do
  @moduledoc """
  Collect dependency information for use in a Software Bill-of-Materials (SBOM).
  """

  alias SBoM.Purl
  alias SBoM.Cpe

  @doc """
  Builds a SBoM for the current Mix project. The result can be exported to
  CycloneDX XML format using the `SBoM.CycloneDX` module. Pass an environment
  of `nil` to include dependencies across all environments.

  Wrap the call to this function with `Mix.Project.in_project/3,4` to select a
  Mix project by path.
  """
  def components_for_project(environment \\ :prod) do
    Mix.Project.get!()

    {deps, not_ok} =
      Mix.Dep.load_on_environment(env: environment)
      |> Enum.split_with(&ok?/1)

    case not_ok do
      [] ->
        components =
          deps
          |> Enum.map(&component_from_dep/1)
          |> Enum.reject(&is_nil/1)

        {:ok, components}

      _ ->
        {:error, :unresolved_dependency}
    end
  end

  defp ok?(dep) do
    Mix.Dep.ok?(dep) || Mix.Dep.compilable?(dep)
  end

  defp component_from_dep(%{opts: opts} = dep) do
    case Map.new(opts) do
      %{optional: true} ->
        # If the dependency is optional at the top level, then we don't include
        # it in the SBoM
        nil

      opts_map ->
        component_from_dep(dep, opts_map)
    end
  end

  defp component_from_dep(%{scm: Hex.SCM}, opts) do
    %{hex: name, lock: lock, dest: dest} = opts
    version = elem(lock, 2)
    sha256 = elem(lock, 3)

    hex_metadata_path = Path.expand("hex_metadata.config", dest)

    metadata =
      case :file.consult(hex_metadata_path) do
        {:ok, metadata} -> metadata
        _ -> []
      end

    {_, description} = List.keyfind(metadata, "description", 0, {"description", ""})
    {_, licenses} = List.keyfind(metadata, "licenses", 0, {"licenses", []})

    %{
      type: "library",
      name: name,
      version: version,
      purl: Purl.hex(name, version, opts[:repo]),
      cpe: Cpe.hex(name, version, opts[:repo]),
      hashes: %{
        "SHA-256" => sha256
      },
      description: description,
      licenses: licenses
    }
  end

  defp component_from_dep(%{scm: Mix.SCM.Git, app: app}, opts) do
    %{git: git, lock: lock, dest: _dest} = opts

    version =
      case opts[:tag] do
        nil ->
          elem(lock, 2)

        tag ->
          tag
      end

    %{
      type: "library",
      name: to_string(app),
      version: version,
      purl: Purl.git(to_string(app), git, version),
      licenses: []
    }
  end

  defp component_from_dep(_dep, _opts), do: nil
end