defmodule Mix.Tasks.Sbom.Cyclonedx do
@shortdoc "Generates CycloneDX SBoM"
use Mix.Task
import Mix.Generator
@schema_versions ["1.6", "1.5", "1.4", "1.3", "1.2", "1.1"]
@default_path "bom.xml"
@default_path_json "bom.json"
@default_schema "1.6"
@default_classification "application"
@default_opts [
schema: @default_schema,
classification: @default_classification
]
@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})
* `--force` (`-f`): overwrite existing files without prompting for
confirmation
* `--dev` (`-d`): include dependencies for non-production environments
(including `dev`, `test` or `docs`); by default only dependencies for
MIX_ENV=prod are returned
* `--recurse` (`-r`): in an umbrella project, generate individual output
files for each application, rather than a single file for the entire
project
* `--schema` (`-s`): schema version to be used, defaults to
"#{@default_schema}"
* `--format` (`-t`): output format: xml or json; defaults to "xml", unless
the output path ends with ".json"
* `--classification` (`-c`): the project classification, e.g. "application",
"library", "framework"; defaults to "#{@default_classification}"
"""
@doc false
@impl Mix.Task
def run(all_args) do
{opts, _args} =
OptionParser.parse!(
all_args,
aliases: [
o: :output,
f: :force,
d: :dev,
r: :recurse,
s: :schema,
t: :format,
c: :classification
],
strict: [
output: :string,
force: :boolean,
dev: :boolean,
recurse: :boolean,
schema: :string,
format: :string,
classification: :string
]
)
opts =
@default_opts
|> Keyword.merge(opts)
|> update_output_path_and_format!()
validate_schema!(opts[:schema])
output_path = opts[:output]
environment = (!opts[:dev] && :prod) || nil
apps = Mix.Project.apps_paths()
if opts[:recurse] && apps do
Enum.each(apps, &generate_bom(&1, output_path, environment, opts[:force]))
else
generate_bom(output_path, environment, opts)
end
end
defp generate_bom(output_path, environment, opts) do
classification = opts[:classification]
case SBoM.components_for_project(classification, environment) do
{:ok, components} ->
xml = SBoM.CycloneDX.bom(components, opts)
create_file(output_path, xml, force: opts[:force])
{:error, :unresolved_dependency} ->
dependency_error()
end
end
defp generate_bom({app, path}, output_path, environment, force) do
Mix.Project.in_project(app, path, fn _module ->
generate_bom(output_path, environment, force)
end)
end
defp dependency_error do
shell = Mix.shell()
shell.error("Unchecked dependencies; please run `mix deps.get`")
Mix.raise("Can't continue due to errors on dependencies")
end
defp update_output_path_and_format!(opts) do
{output, format} =
case {opts[:output], opts[:format]} do
{nil, nil} ->
{@default_path, format_from_path(@default_path)}
{output, nil} ->
{output, format_from_path(output)}
{nil, "xml"} ->
{@default_path, "xml"}
{nil, "json"} ->
{@default_path_json, "json"}
{output, format} when format in ["xml", "json"] ->
{output, format}
{_, format} ->
Mix.raise("Unsupported output format: #{format}")
end
Keyword.merge(opts, output: output, format: format)
end
defp format_from_path(path) do
case Path.extname(path) do
".json" -> "json"
_ -> "xml"
end
end
defp validate_schema!(schema) do
if schema not in @schema_versions do
shell = Mix.shell()
shell.error(
"invalid cyclonedx schema version, available versions are #{@schema_versions |> Enum.join(", ")}"
)
Mix.raise("Give correct cyclonedx schema version to continue.")
end
end
end