lib/sbom/cyclonedx.ex

defmodule SBoM.CycloneDX do
  @moduledoc """
  Generate a CycloneDX SBoM in XML format.
  """

  alias SBoM.License

  @doc """
  Generate a CycloneDX SBoM in XML format from the specified list of
  components. Returns an `iolist`, which may be written to a file or IO device,
  or converted to a String using `IO.iodata_to_binary/1`

  If no serial number is specified a random UUID is generated.
  """

  def bom(components, options \\ []) do
    bom =
      case options[:schema] do
        "1.1" ->
          {:bom,
           [
             serialNumber: options[:serial] || uuid(),
             xmlns: "http://cyclonedx.org/schema/bom/1.1"
           ], [{:components, [], Enum.map(components, &component/1)}]}

        _ ->
          {:bom,
           [
             serialNumber: options[:serial] || uuid(),
             xmlns: "http://cyclonedx.org/schema/bom/1.2"
           ],
           [
             {:metadata, [],
              [
                {:timestamp, [], [[DateTime.utc_now() |> DateTime.to_iso8601()]]},
                {:tools, [], [tool: [name: [["SBoM Mix task for Elixir"]]]]}
              ]},
             {:components, [], Enum.map(components, &component/1)}
           ]}
      end

    :xmerl.export_simple([bom], :xmerl_xml)
  end

  defp component(component) do
    {:component, [type: component.type], component_fields(component)}
  end

  defp component_fields(component) do
    component |> Enum.map(&component_field/1) |> Enum.reject(&is_nil/1)
  end

  @simple_fields [:name, :version, :purl, :cpe, :description]

  defp component_field({field, value}) when field in @simple_fields and not is_nil(value) do
    {field, [], [[value]]}
  end

  defp component_field({:hashes, hashes}) when is_map(hashes) do
    {:hashes, [], Enum.map(hashes, &hash/1)}
  end

  defp component_field({:licenses, [_ | _] = licenses}) do
    {:licenses, [], Enum.map(licenses, &license/1)}
  end

  defp component_field(_other), do: nil

  defp license(name) do
    # If the name is a recognized SPDX license ID, or if we can turn it into
    # one, we return a bom:license with a bom:id element
    case License.spdx_id(name) do
      nil ->
        {:license, [],
         [
           {:name, [], [[name]]}
         ]}

      id ->
        {:license, [],
         [
           {:id, [], [[id]]}
         ]}
    end
  end

  defp hash({algorithm, hash}) do
    {:hash, [alg: algorithm], [[hash]]}
  end

  defp uuid() do
    [
      :crypto.strong_rand_bytes(4),
      :crypto.strong_rand_bytes(2),
      <<4::4, :crypto.strong_rand_bytes(2)::binary-size(12)-unit(1)>>,
      <<2::2, :crypto.strong_rand_bytes(2)::binary-size(14)-unit(1)>>,
      :crypto.strong_rand_bytes(6)
    ]
    |> Enum.map(&Base.encode16(&1, case: :lower))
    |> Enum.join("-")
  end
end