Skip to content

Commit

Permalink
Release v0.6.0
Browse files Browse the repository at this point in the history
  • Loading branch information
voltone committed Nov 11, 2019
1 parent f7dcded commit 99106f5
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 73 deletions.
98 changes: 53 additions & 45 deletions lib/sbom.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
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!()
Expand Down Expand Up @@ -44,54 +52,54 @@ defmodule SBoM do
end

defp component_from_dep(%{scm: Hex.SCM}, opts) do
case opts do
%{hex: name, lock: lock, dest: dest} ->
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

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

%{
type: "library",
name: name,
version: version,
purl: Purl.hex(name, version, opts[:repo]),
hashes: %{
"SHA-256" => sha256
},
licenses: licenses
}
end
%{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)
{_, 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
case opts do
%{git: git, lock: lock} ->
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
%{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
Expand Down
33 changes: 33 additions & 0 deletions lib/sbom/cpe.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule SBoM.Cpe do
@moduledoc false

def hex(name, version, repo \\ "hexpm") do
do_hex(String.downcase(name), version, String.downcase(repo))
end

defp do_hex("hex_core", version, "hexpm") do
"cpe:2.3:a:hex:hex_core:#{version}:*:*:*:*:*:*:*"
end

defp do_hex("plug", version, "hexpm") do
"cpe:2.3:a:elixir-plug:plug:#{version}:*:*:*:*:*:*:*"
end

defp do_hex("phoenix", version, "hexpm") do
"cpe:2.3:a:phoenixframework:phoenix:#{version}:*:*:*:*:*:*:*"
end

defp do_hex("coherence", version, "hexpm") do
"cpe:2.3:a:coherence_project:coherence:#{version}:*:*:*:*:*:*:*"
end

defp do_hex("xain", version, "hexpm") do
"cpe:2.3:a:emetrotel:xain:#{version}:*:*:*:*:*:*:*"
end

defp do_hex("sweet_xml", version, "hexpm") do
"cpe:2.3:a:kbrw:sweet_xml:#{version}:*:*:*:*:*:*:*"
end

defp do_hex(_name, _version, _repo), do: nil
end
48 changes: 30 additions & 18 deletions lib/sbom/cyclonedx.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
defmodule SBoM.CycloneDX do
@moduledoc false
@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, serial \\ nil)

def bom(components, nil) do
Expand All @@ -19,27 +28,30 @@ defmodule SBoM.CycloneDX do
:xmerl.export_simple([bom], :xmerl_xml)
end

defp component(%{hashes: %{}} = component) do
{:component, [type: component.type],
[
{:name, [], [[component.name]]},
{:version, [], [[component.version]]},
{:purl, [], [[component.purl]]},
{:hashes, [], Enum.map(component.hashes, &hash/1)},
{:licenses, [], Enum.map(component.licenses, &license/1)}
]}
defp component(component) do
{:component, [type: component.type], component_fields(component)}
end

defp component(component) do
{:component, [type: component.type],
[
{:name, [], [[component.name]]},
{:version, [], [[component.version]]},
{:purl, [], [[component.purl]]},
{:licenses, [], Enum.map(component.licenses, &license/1)}
]}
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
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule SBoM.MixProject do
use Mix.Project

@version "0.5.1"
@version "0.6.0"

def project do
[
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/sample1/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule Sample1.MixProject do
defp deps do
[
{:hackney, "~> 1.15"},
{:sweet_xml, "~> 0.6.6"},
{:jason, "~> 1.1", optional: true},
{:ex_doc, "~> 0.21.2", only: :dev},
{:meck, "~> 0.8.13", only: :test}
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/sample1/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"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"},
}
1 change: 1 addition & 0 deletions test/fixtures/with_path_dep/mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"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"},
}
29 changes: 29 additions & 0 deletions test/mix/tasks/sbom.cyclonedx_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Mix.Tasks.Sbom.CyclonedxTest do
use ExUnit.Case

setup_all do
Mix.shell(Mix.Shell.Process)
:ok
end

setup do
Mix.Shell.Process.flush()
:ok
end

test "mix task" do
Mix.Project.in_project(__MODULE__, "test/fixtures/sample1", fn _mod ->
Mix.Task.rerun("deps.clean", ["--all"])

assert_raise Mix.Error, "Can't continue due to errors on dependencies", fn ->
Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f"])
end

Mix.Task.rerun("deps.get")
Mix.Shell.Process.flush()

Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f"])
assert_received {:mix_shell, :info, ["* creating bom.xml"]}
end)
end
end
2 changes: 1 addition & 1 deletion test/sbom/cyclonedx_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defmodule SBoM.CycloneDXTest do
assert xml =~ ~s(<name>name</name>)
assert xml =~ ~s(<version>0.0.1</version>)
assert xml =~ ~s(<purl>pkg:hex/[email protected]</purl>)
assert xml =~ ~s(<licenses/>)
refute xml =~ ~s(<licenses>)
end

test "component with SPDX license" do
Expand Down
28 changes: 20 additions & 8 deletions test/sbom_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,54 @@ defmodule SBoMTest do
describe "components_for_project" do
test "basic project" do
Mix.Project.in_project(:sample1, "test/fixtures/sample1", fn _mod ->
Mix.Task.run("deps.clean", ["--all"])
Mix.Task.rerun("deps.clean", ["--all"])
assert {:error, :unresolved_dependency} = SBoM.components_for_project()

Mix.Task.run("deps.get")
Mix.Task.rerun("deps.get")
assert {:ok, list} = SBoM.components_for_project()
assert length(list) == 8
assert length(list) == 9
assert Enum.find(list, &match?(%{name: "hackney"}, &1))
assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1))
refute Enum.find(list, &match?(%{name: "ex_doc"}, &1))
refute Enum.find(list, &match?(%{name: "meck"}, &1))
refute Enum.find(list, &match?(%{name: "jason"}, &1))

assert {:ok, list} = SBoM.components_for_project(nil)
assert length(list) == 14
assert length(list) == 15
assert Enum.find(list, &match?(%{name: "hackney"}, &1))
assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1))
assert Enum.find(list, &match?(%{name: "ex_doc"}, &1))
assert Enum.find(list, &match?(%{name: "meck"}, &1))
refute Enum.find(list, &match?(%{name: "jason"}, &1))

assert %{cpe: "cpe:2.3:a:kbrw:sweet_xml:0.6.6:*:*:*:*:*:*:*"} =
Enum.find(list, &match?(%{name: "sweet_xml"}, &1))

assert %{
licenses: ["Apache 2.0"],
description: "ExDoc is a documentation generation tool for Elixir"
} = Enum.find(list, &match?(%{name: "ex_doc"}, &1))
end)
end

test "project with path dependency" do
Mix.Project.in_project(:with_path_dep, "test/fixtures/with_path_dep", fn _mod ->
Mix.Task.run("deps.clean", ["--all"])
Mix.Task.rerun("deps.clean", ["--all"])
assert {:error, :unresolved_dependency} = SBoM.components_for_project()

Mix.Task.run("deps.get")
Mix.Task.rerun("deps.get")
assert {:ok, list} = SBoM.components_for_project()
assert length(list) == 9
assert length(list) == 10
assert Enum.find(list, &match?(%{name: "hackney"}, &1))
assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1))
refute Enum.find(list, &match?(%{name: "ex_doc"}, &1))
refute Enum.find(list, &match?(%{name: "meck"}, &1))
assert Enum.find(list, &match?(%{name: "jason"}, &1))

assert {:ok, list} = SBoM.components_for_project(nil)
assert length(list) == 9
assert length(list) == 10
assert Enum.find(list, &match?(%{name: "hackney"}, &1))
assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1))
refute Enum.find(list, &match?(%{name: "ex_doc"}, &1))
refute Enum.find(list, &match?(%{name: "meck"}, &1))
assert Enum.find(list, &match?(%{name: "jason"}, &1))
Expand Down

0 comments on commit 99106f5

Please sign in to comment.