diff --git a/.gitignore b/.gitignore index 49a0561..807dd2d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,3 @@ bom.xml # Ignore dependency artifacts from test fixtures /test/fixtures/*/deps/ /test/fixtures/*/_build/ - -mix.lock - diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..ff52406 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 26.1.2 +elixir 1.15.7-otp-26 diff --git a/lib/mix/tasks/sbom.cyclonedx.ex b/lib/mix/tasks/sbom.cyclonedx.ex index 832d2dc..7e7da84 100644 --- a/lib/mix/tasks/sbom.cyclonedx.ex +++ b/lib/mix/tasks/sbom.cyclonedx.ex @@ -4,7 +4,10 @@ defmodule Mix.Tasks.Sbom.Cyclonedx do use Mix.Task import Mix.Generator - @default_path "bom.xml" + @default_encoding "xml" + @default_filename "bom" + @valid_schema_versions ["1.1", "1.2"] + @default_schema "1.2" @moduledoc """ Generates a Software Bill-of-Materials (SBoM) in CycloneDX format. @@ -12,7 +15,7 @@ defmodule Mix.Tasks.Sbom.Cyclonedx do ## Options * `--output` (`-o`): the full path to the SBoM output file (default: - #{@default_path}) + #{@default_filename}.#{@default_encoding}) * `--force` (`-f`): overwrite existing files without prompting for confirmation * `--dev` (`-d`): include dependencies for non-production environments @@ -31,19 +34,24 @@ defmodule Mix.Tasks.Sbom.Cyclonedx do {opts, _args} = OptionParser.parse!( all_args, - aliases: [o: :output, f: :force, d: :dev, r: :recurse, s: :schema], + aliases: [o: :output, f: :force, d: :dev, r: :recurse, s: :schema, e: :encoding], strict: [ output: :string, force: :boolean, dev: :boolean, recurse: :boolean, - schema: :string + schema: :string, + encoding: :string ] ) - output_path = opts[:output] || @default_path - valiate_schema(opts) + opts = + opts + |> Keyword.put_new(:encoding, @default_encoding) + |> Keyword.put_new(:schema, @default_schema) + output_path = opts[:output] || @default_filename <> "." <> opts[:encoding] + validate_schema(opts[:schema]) environment = (!opts[:dev] && :prod) || nil apps = Mix.Project.apps_paths() @@ -58,8 +66,8 @@ defmodule Mix.Tasks.Sbom.Cyclonedx do 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]) + bom = SBoM.CycloneDX.bom(components, opts) + create_file(output_path, bom, force: opts[:force]) {:error, :unresolved_dependency} -> dependency_error() @@ -78,19 +86,21 @@ defmodule Mix.Tasks.Sbom.Cyclonedx do Mix.raise("Can't continue due to errors on dependencies") end - defp valiate_schema(opts) do - schema_versions = ["1.2", "1.1"] + defp validate_schema(nil) do + :ok + end - if opts[:schema] && opts[:schema] not in schema_versions do - shell = Mix.shell() + defp validate_schema(schema) when schema in @valid_schema_versions do + :ok + end - shell.error( - "invalid cyclonedx schema version, available versions are #{ - schema_versions |> Enum.join(", ") - }" - ) + defp validate_schema(_schema) do + shell = Mix.shell() - Mix.raise("Give correct cyclonedx schema version to continue.") - end + shell.error( + "invalid cyclonedx schema version, available versions are #{@valid_schema_versions |> Enum.join(", ")}" + ) + + Mix.raise("Give correct cyclonedx schema version to continue.") end end diff --git a/lib/sbom.ex b/lib/sbom.ex index 19a27ea..137d87b 100644 --- a/lib/sbom.ex +++ b/lib/sbom.ex @@ -5,6 +5,7 @@ defmodule SBoM do alias SBoM.Purl alias SBoM.Cpe + alias SBoM.License @doc """ Builds a SBoM for the current Mix project. The result can be exported to @@ -66,18 +67,22 @@ defmodule SBoM do {_, description} = List.keyfind(metadata, "description", 0, {"description", ""}) {_, licenses} = List.keyfind(metadata, "licenses", 0, {"licenses", []}) + description = description |> String.split("\n", parts: 2) |> List.first() %{ type: "library", name: name, version: version, + description: description, purl: Purl.hex(name, version, opts[:repo]), cpe: Cpe.hex(name, version, opts[:repo]), - hashes: %{ - "SHA-256" => sha256 - }, - description: description, - licenses: licenses + licenses: License.parse(licenses), + hashes: [ + %{ + alg: "SHA-256", + content: sha256 + } + ] } end diff --git a/lib/sbom/cpe.ex b/lib/sbom/cpe.ex index b78d414..dd65b09 100644 --- a/lib/sbom/cpe.ex +++ b/lib/sbom/cpe.ex @@ -29,5 +29,5 @@ defmodule SBoM.Cpe do "cpe:2.3:a:kbrw:sweet_xml:#{version}:*:*:*:*:*:*:*" end - defp do_hex(_name, _version, _repo), do: nil + defp do_hex(_name, _version, _repo), do: "" end diff --git a/lib/sbom/cyclonedx.ex b/lib/sbom/cyclonedx.ex index d3a5267..0d04160 100644 --- a/lib/sbom/cyclonedx.ex +++ b/lib/sbom/cyclonedx.ex @@ -3,7 +3,8 @@ defmodule SBoM.CycloneDX do Generate a CycloneDX SBoM in XML format. """ - alias SBoM.License + alias __MODULE__.XML + alias __MODULE__.JSON @doc """ Generate a CycloneDX SBoM in XML format from the specified list of @@ -13,90 +14,10 @@ defmodule SBoM.CycloneDX do 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]]} - ]} + def bom(components, opts \\ []) do + case opts[:encoding] || "xml" do + "xml" -> XML.bom(components, opts) + "json" -> JSON.bom(components, opts) 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 diff --git a/lib/sbom/cyclonedx/json.ex b/lib/sbom/cyclonedx/json.ex new file mode 100644 index 0000000..fe020e5 --- /dev/null +++ b/lib/sbom/cyclonedx/json.ex @@ -0,0 +1,30 @@ +defmodule SBoM.CycloneDX.JSON do + alias SBoM.UUID + alias SBoM.JsonEncoder + + def bom(components, opts) do + components + |> base(opts) + |> Map.merge(metadata(opts[:schema])) + |> JsonEncoder.encode() + end + + defp base(components, opts) do + %{ + bomFormat: "CycloneDX", + specVersion: opts[:schema], + serialNumber: opts[:serial] || UUID.generate(), + components: components, + version: 1 + } + end + + defp metadata("1.2") do + %{ + timestamp: DateTime.to_iso8601(DateTime.utc_now()), + tools: %{name: "SBoM Mix task for Elixir"} + } + end + + defp metadata(_), do: %{} +end diff --git a/lib/sbom/cyclonedx/xml.ex b/lib/sbom/cyclonedx/xml.ex new file mode 100644 index 0000000..5a125b7 --- /dev/null +++ b/lib/sbom/cyclonedx/xml.ex @@ -0,0 +1,74 @@ +defmodule SBoM.CycloneDX.XML do + alias SBoM.UUID + + @simple_fields [:name, :version, :purl, :cpe, :description] + + def bom(components, opts) do + bom = [ + {:bom, header(opts[:schema], opts[:serial]), content(opts[:schema], components)} + ] + + bom + |> :xmerl.export_simple(:xmerl_xml) + |> to_string() + end + + defp header(version, serial) do + [ + serialNumber: serial || UUID.generate(), + xmlns: xmlns(version) + ] + end + + defp xmlns("1.2"), do: "http://cyclonedx.org/schema/bom/1.2" + defp xmlns(_), do: "http://cyclonedx.org/schema/bom/1.1" + + defp content("1.2", components) do + [ + {:metadata, [], + [ + {:timestamp, [], [[DateTime.utc_now() |> DateTime.to_iso8601()]]}, + {:tools, [], [tool: [name: [["SBoM Mix task for Elixir"]]]]} + ]}, + {:components, [], Enum.map(components, &component/1)} + ] + end + + defp content(_, components) do + [{:components, [], Enum.map(components, &component/1)}] + 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 + + 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_list(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(%{license: %{id: id}}) do + {:license, [], [{:id, [], [[id]]}]} + end + + defp license(%{license: %{name: name}}) do + {:license, [], [{:name, [], [[name]]}]} + end + + defp hash(%{alg: algorithm, content: hash}) do + {:hash, [alg: algorithm], [[hash]]} + end +end diff --git a/lib/sbom/json_encoder.ex b/lib/sbom/json_encoder.ex new file mode 100644 index 0000000..4dc0b91 --- /dev/null +++ b/lib/sbom/json_encoder.ex @@ -0,0 +1,70 @@ +defmodule SBoM.JsonEncoder do + def encode(data) when is_map(data) do + data + |> Enum.map(&~s/"#{to_string(elem(&1, 0))}":#{encode(elem(&1, 1))}/) + |> Enum.join(",") + |> then(&~s/{#{&1}}/) + end + + def encode(data) when is_list(data) do + if is_tuple(List.first(data)) do + data + |> Map.new() + |> encode() + else + data + |> Enum.map(&encode/1) + |> Enum.join(",") + |> then(&~s/[#{&1}]/) + end + end + + def encode(nil), do: "null" + def encode(data) when is_tuple(data), do: data |> Tuple.to_list() |> encode() + def encode(data) when is_number(data), do: ~s/#{data}/ + def encode(data) when is_boolean(data), do: ~s/#{to_string(data)}/ + def encode(data) when is_atom(data), do: data |> to_string() |> encode() + def encode(data) when is_binary(data), do: <> <> encode_binary_recursive(data) <> <> + + def encode(data) do + ~s/#{to_string(inspect(data))}/ + end + + defp encode_binary_recursive(data, acc \\ []) + + defp encode_binary_recursive(<>, acc) do + encode_binary_recursive(tail, encode_binary_character(head, acc)) + end + + defp encode_binary_recursive(<<>>, acc), do: acc |> Enum.reverse() |> to_string + + defp encode_binary_character(?", acc), do: [?", ?\\ | acc] + defp encode_binary_character(?\b, acc), do: [?b, ?\\ | acc] + defp encode_binary_character(?\f, acc), do: [?f, ?\\ | acc] + defp encode_binary_character(?\n, acc), do: [?n, ?\\ | acc] + defp encode_binary_character(?\r, acc), do: [?r, ?\\ | acc] + defp encode_binary_character(?\t, acc), do: [?t, ?\\ | acc] + defp encode_binary_character(?\\, acc), do: [?\\, ?\\ | acc] + + defp encode_binary_character(char, acc) when is_number(char) and char < 32 do + encode_hexadecimal_unicode_control_character(char, [?u, ?\\ | acc]) + end + + # anything else besides these control characters, just let it through + defp encode_binary_character(char, acc) when is_number(char), do: [char | acc] + + defp encode_hexadecimal_unicode_control_character(char, acc) when is_number(char) do + [ + char + |> Integer.to_charlist(16) + |> zeropad_hexadecimal_unicode_control_character + |> Enum.reverse() + | acc + ] + end + + defp zeropad_hexadecimal_unicode_control_character([a, b, c]), do: [?0, a, b, c] + defp zeropad_hexadecimal_unicode_control_character([a, b]), do: [?0, ?0, a, b] + defp zeropad_hexadecimal_unicode_control_character([a]), do: [?0, ?0, ?0, a] + defp zeropad_hexadecimal_unicode_control_character(iolist) when is_list(iolist), do: iolist +end diff --git a/lib/sbom/license.ex b/lib/sbom/license.ex index e5054d1..3cc215b 100644 --- a/lib/sbom/license.ex +++ b/lib/sbom/license.ex @@ -404,6 +404,18 @@ defmodule SBoM.License do "zlib-acknowledgement" => "zlib-acknowledgement" } + def parse(licenses) when is_list(licenses), do: Enum.map(licenses, &parse/1) + + def parse(name) do + id = spdx_id(name) + + if id do + %{license: %{id: id}} + else + %{license: %{name: name}} + end + end + def spdx_id(id) do @spdx_id[normalize(id)] end diff --git a/lib/sbom/uuid.ex b/lib/sbom/uuid.ex new file mode 100644 index 0000000..ff13eda --- /dev/null +++ b/lib/sbom/uuid.ex @@ -0,0 +1,17 @@ +defmodule SBoM.UUID do + def generate do + "urn:uuid:" <> uuid() + 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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c9bfaa9 --- /dev/null +++ b/mix.lock @@ -0,0 +1,8 @@ +%{ + "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, + "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, +} diff --git a/test/fixtures/sample1/mix.lock b/test/fixtures/sample1/mix.lock index 6a61594..c9edffd 100644 --- a/test/fixtures/sample1/mix.lock +++ b/test/fixtures/sample1/mix.lock @@ -1,18 +1,18 @@ %{ - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, - "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~> 3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, } diff --git a/test/fixtures/with_path_dep/mix.lock b/test/fixtures/with_path_dep/mix.lock index 4cb11e8..4337b3f 100644 --- a/test/fixtures/with_path_dep/mix.lock +++ b/test/fixtures/with_path_dep/mix.lock @@ -1,12 +1,12 @@ %{ - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, - "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~> 3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, } diff --git a/test/mix/tasks/sbom.cyclonedx_test.exs b/test/mix/tasks/sbom.cyclonedx_test.exs index 2812458..2b7d683 100644 --- a/test/mix/tasks/sbom.cyclonedx_test.exs +++ b/test/mix/tasks/sbom.cyclonedx_test.exs @@ -1,5 +1,6 @@ -defmodule Mix.Tasks.Sbom.CyclonedxTest do +defmodule Mix.Tasks.SBoM.CyclonedxTest do use ExUnit.Case + alias SBoM.UUID setup_all do Mix.shell(Mix.Shell.Process) @@ -37,4 +38,14 @@ defmodule Mix.Tasks.Sbom.CyclonedxTest do end end) end + + test "json write" do + file_name = "#{UUID.generate()}.json" + + Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f", "-e", "json", "-o", file_name]) + expected_msg = "* creating #{file_name}" + assert_received {:mix_shell, :info, [^expected_msg]} + assert File.read!(file_name) =~ ~s/"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.2\"/ + assert File.rm!(file_name) + end end diff --git a/test/sbom/cyclonedx/json_test.exs b/test/sbom/cyclonedx/json_test.exs new file mode 100644 index 0000000..49009b7 --- /dev/null +++ b/test/sbom/cyclonedx/json_test.exs @@ -0,0 +1,94 @@ +defmodule SBoM.CycloneDX.JSONTest do + use ExUnit.Case + alias SBoM.CycloneDX.JSON + + describe "bom/2" do + test "serial number UUID generation" do + assert JSON.bom([], schema: "1.2") =~ + ~r("serialNumber":"urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + end + + test "component without license" do + json = + JSON.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [] + } + ], + schema: "1.2" + ) + + assert json =~ ~s("type":"library") + assert json =~ ~s("name":"name") + assert json =~ ~s("version":"0.0.1") + assert json =~ ~s("purl":"pkg:hex/name@0.0.1") + assert json =~ ~s("licenses":[]) + end + + test "component with SPDX license" do + json = + JSON.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [%{license: %{id: "Apache-2.0"}}] + } + ], + schema: "1.2" + ) + + assert json =~ ~s("licenses":[{"license":{"id":"Apache-2.0"}}]) + end + + test "component with other license" do + json = + JSON.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [%{license: %{name: "Some other license"}}] + } + ], + schema: "1.2" + ) + + assert json =~ ~s("licenses":[{"license":{"name":"Some other license"}}]) + end + + test "component with hash" do + json = + JSON.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [], + hashes: [ + %{ + alg: "SHA-256", + content: "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe" + } + ] + } + ], + schema: "1.2" + ) + + assert json =~ + ~s("hashes":[{"alg":"SHA-256","content":"fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}]}]) + end + end +end diff --git a/test/sbom/cyclonedx/xml_test.exs b/test/sbom/cyclonedx/xml_test.exs new file mode 100644 index 0000000..a071f7e --- /dev/null +++ b/test/sbom/cyclonedx/xml_test.exs @@ -0,0 +1,94 @@ +defmodule SBoM.CycloneDX.XMLTest do + use ExUnit.Case + alias SBoM.CycloneDX.XML + + describe "bom/2" do + test "serial number UUID generation" do + assert XML.bom([], schema: "1.2") =~ + ~r(serialNumber="urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + end + + test "component without license" do + xml = + XML.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [] + } + ], + schema: "1.2" + ) + + assert xml =~ ~s() + assert xml =~ ~s(name) + assert xml =~ ~s(0.0.1) + assert xml =~ ~s(pkg:hex/name@0.0.1) + refute xml =~ ~s() + end + + test "component with SPDX license" do + xml = + XML.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [%{license: %{id: "Apache-2.0"}}] + } + ], + schema: "1.2" + ) + + assert xml =~ ~s(Apache-2.0) + end + + test "component with other license" do + xml = + XML.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [%{license: %{name: "Some other license"}}] + } + ], + schema: "1.2" + ) + + assert xml =~ ~s(Some other license) + end + + test "component with hash" do + xml = + XML.bom( + [ + %{ + type: "library", + name: "name", + version: "0.0.1", + purl: "pkg:hex/name@0.0.1", + licenses: [], + hashes: [ + %{ + alg: "SHA-256", + content: "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe" + } + ] + } + ], + schema: "1.2" + ) + + assert xml =~ + ~s(fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe) + end + end +end diff --git a/test/sbom/cyclonedx_test.exs b/test/sbom/cyclonedx_test.exs index 36e580b..ba07882 100644 --- a/test/sbom/cyclonedx_test.exs +++ b/test/sbom/cyclonedx_test.exs @@ -1,89 +1,14 @@ defmodule SBoM.CycloneDXTest do use ExUnit.Case - import SBoM.CycloneDX - - doctest SBoM.CycloneDX + alias SBoM.CycloneDX describe "bom" do - test "serial number UUID generation" do - assert [] |> bom() |> to_string() =~ - ~r(serialNumber="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") - end - - test "component without license" do - xml = - [ - %{ - type: "library", - name: "name", - version: "0.0.1", - purl: "pkg:hex/name@0.0.1", - licenses: [] - } - ] - |> bom() - |> to_string() - - assert xml =~ ~s() - assert xml =~ ~s(name) - assert xml =~ ~s(0.0.1) - assert xml =~ ~s(pkg:hex/name@0.0.1) - refute xml =~ ~s() + test "with json encoding" do + assert <<"{\"version\":1,\"components\":[]" <> _>> = CycloneDX.bom([], encoding: "json") end - test "component with SPDX license" do - xml = - [ - %{ - type: "library", - name: "name", - version: "0.0.1", - purl: "pkg:hex/name@0.0.1", - licenses: ["Apache-2.0"] - } - ] - |> bom() - |> to_string() - - assert xml =~ ~s(Apache-2.0) - end - - test "component with other license" do - xml = - [ - %{ - type: "library", - name: "name", - version: "0.0.1", - purl: "pkg:hex/name@0.0.1", - licenses: ["Some other license"] - } - ] - |> bom() - |> to_string() - - assert xml =~ ~s(Some other license) - end - - test "component with hash" do - xml = - [ - %{ - type: "library", - name: "name", - version: "0.0.1", - purl: "pkg:hex/name@0.0.1", - licenses: [], - hashes: %{ - "SHA-256" => "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe" - } - } - ] - |> bom() - |> to_string() - - assert xml =~ - ~s(fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe) + test "with default encoding" do + assert <<"" <> _>> = CycloneDX.bom([]) end end end diff --git a/test/sbom/json_encoder_test.exs b/test/sbom/json_encoder_test.exs new file mode 100644 index 0000000..39aace1 --- /dev/null +++ b/test/sbom/json_encoder_test.exs @@ -0,0 +1,25 @@ +defmodule SBoM.JsonEncoderTest do + use ExUnit.Case + alias SBoM.JsonEncoder + + describe "encode/1" do + test "test with all types of data" do + assert ~s/"data"/ == JsonEncoder.encode("data") + assert ~s/["data"]/ == JsonEncoder.encode(["data"]) + assert ~s/{"key":"value"}/ == JsonEncoder.encode(%{key: "value"}) + assert ~s/null/ == JsonEncoder.encode(nil) + assert ~s/false/ == JsonEncoder.encode(false) + assert ~s/true/ == JsonEncoder.encode(true) + assert ~s/"test"/ == JsonEncoder.encode(:test) + assert ~s/42/ == JsonEncoder.encode(42) + assert ~s/99.99/ == JsonEncoder.encode(99.99) + assert ~s/9.9e100/ == JsonEncoder.encode(9.9e100) + assert ~s/"hello\\nworld"/ == JsonEncoder.encode("hello\nworld") + assert ~s/"\\nhello\\nworld\\n"/ == JsonEncoder.encode("\nhello\nworld\n") + assert ~s/"\\""/ == JsonEncoder.encode("\"") + assert ~s/"\\u0000\"/ == JsonEncoder.encode("\0") + assert ~s/{"key":[{"key":"teste"}]}/ == JsonEncoder.encode(%{key: [%{key: "teste"}]}) + assert ~s/{"key":"data"}/ == JsonEncoder.encode(key: "data") + end + end +end diff --git a/test/sbom/license_test.exs b/test/sbom/license_test.exs index 8829886..2bfa0a3 100644 --- a/test/sbom/license_test.exs +++ b/test/sbom/license_test.exs @@ -4,7 +4,7 @@ defmodule SBoM.LicenseTest do doctest SBoM.License - test :spdx_id do + test "spdx_id" do assert "0BSD" = spdx_id("0BSD") assert "MIT" = spdx_id("mit") assert "BSD-3-Clause" = spdx_id("BSD 3-clause") @@ -18,4 +18,14 @@ defmodule SBoM.LicenseTest do assert "MPL-2.0" = spdx_id("Mozilla Public License version 2.0") assert "MPL-2.0" = spdx_id("Mozilla Public License, version 2.0") end + + test "parse" do + assert %{license: %{id: "Apache-2.0"}} == parse("Apache-2.0") + assert %{license: %{name: "other license"}} == parse("other license") + + assert [ + %{license: %{name: "other license"}}, + %{license: %{id: "Apache-2.0"}} + ] == parse(["other license", "Apache-2.0"]) + end end diff --git a/test/sbom/uuid_test.exs b/test/sbom/uuid_test.exs new file mode 100644 index 0000000..3a7497a --- /dev/null +++ b/test/sbom/uuid_test.exs @@ -0,0 +1,11 @@ +defmodule SBoM.UUIDTest do + use ExUnit.Case + alias SBoM.UUID + + describe "generate" do + test "serial number generation" do + assert UUID.generate() =~ + ~r(urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}) + end + end +end diff --git a/test/sbom_test.exs b/test/sbom_test.exs index 384d8f2..9aab2a9 100644 --- a/test/sbom_test.exs +++ b/test/sbom_test.exs @@ -39,7 +39,7 @@ defmodule SBoMTest do Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) assert %{ - licenses: ["Apache 2.0"], + licenses: [%{license: %{id: "Apache-2.0"}}], description: "ExDoc is a documentation generation tool for Elixir" } = Enum.find(list, &match?(%{name: "ex_doc"}, &1)) end)