Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support json output format #13

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,3 @@ bom.xml
# Ignore dependency artifacts from test fixtures
/test/fixtures/*/deps/
/test/fixtures/*/_build/

mix.lock

2 changes: 2 additions & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erlang 26.1.2
elixir 1.15.7-otp-26
48 changes: 29 additions & 19 deletions lib/mix/tasks/sbom.cyclonedx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ 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.

## 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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
15 changes: 10 additions & 5 deletions lib/sbom.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/sbom/cpe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 6 additions & 85 deletions lib/sbom/cyclonedx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
30 changes: 30 additions & 0 deletions lib/sbom/cyclonedx/json.ex
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions lib/sbom/cyclonedx/xml.ex
Original file line number Diff line number Diff line change
@@ -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
Loading