Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
voltone committed Oct 24, 2019
0 parents commit 221dae4
Show file tree
Hide file tree
Showing 20 changed files with 1,240 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"]
]
30 changes: 30 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
sbom-*.tar

# Ignore SBoM
bom.xml

# Ignore dependency artifacts from test fixtures
/test/fixtures/*/deps/
/test/fixtures/*/_build/
27 changes: 27 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Copyright (c) 2019, Bram Verburg
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SBoM

Generates a Software Bill-of-Materials (SBoM) for Mix projects, in [CycloneDX](https://cyclonedx.org)
format.

Full documentation can be found at [https://hexdocs.pm/sbom](https://hexdocs.pm/sbom).

## Installation

To install the Mix task globally on your system, run `mix archive.install hex sbom`.

Alternatively, the package can be added to a project's dependencies to make the
Mix task available for that project only:

```elixir
def deps do
[
{:sbom, "~> 0.5.0", only: :dev, runtime: false}
]
end
```

## Usage

To produce a CycloneDX SBoM, run `mix sbom.cyclonedx` from the project
directory. The result is written to a file named `bom.xml`, unless a different
name is specified using the `-o` option.

By default only the dependencies used in production are included. To include all
dependencies, including those for the 'dev' and 'test' environments, pass the
`-d` command line option: `mix sbom.cyclonedx -d`.

*Note that MIX_ENV does not affect which dependencies are included in the
output; the task should normally be run in the default (dev) environment*

For more information on the command line arguments accepted by the Mix task
run `mix help sbom.cyclonedx`.

## NPM packages and other dependencies

This tool only considers Hex, GitHub and BitBucket dependencies managed through
Mix. To build a comprehensive SBoM of a deployment, including NPM and/or
operating system packages, it may be necessary to merge multiple CycloneDX files
into one.

The [@cyclonedx/bom](https://www.npmjs.com/package/@cyclonedx/bom) tool on NPM
can not only generate an SBoM for your JavaScript assets, but it can also merge
in the output of the 'sbom.cyclonedx' Mix task and other scanners, through the
'-a' option, producing a single CycloneDX XML file.
72 changes: 72 additions & 0 deletions lib/mix/tasks/sbom.cyclonedx.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule Mix.Tasks.Sbom.Cyclonedx do
@shortdoc "Generates CycloneDX SBoM"

use Mix.Task
import Mix.Generator

@default_path "bom.xml"

@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`): overwite 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
"""

@doc false
def run(all_args) do
{opts, _args} =
OptionParser.parse!(
all_args,
aliases: [o: :output, f: :force, d: :dev, r: :recurse],
strict: [output: :string, force: :boolean, dev: :boolean, recurse: :boolean]
)

output_path = opts[:output] || @default_path
environment = (!opts[:dev] && :prod) || nil

# Mix.Task.run("deps.loadpaths", ["--no-compile", "--no-load-deps"])

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[:force])
end
end

defp generate_bom(output_path, environment, force) do
case SBoM.components_for_project(environment) do
{:ok, components} ->
xml = SBoM.CycloneDX.bom(components)
create_file(output_path, xml, force: 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
end
98 changes: 98 additions & 0 deletions lib/sbom.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
defmodule SBoM do
alias SBoM.Purl

@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.
"""
def components_for_project(environment \\ :prod) do
Mix.Project.get!()

{deps, not_ok} =
Mix.Dep.load_on_environment(env: environment)
|> Enum.split_with(&ok?/1)

case not_ok do
[] ->
components =
deps
|> Enum.map(&component_from_dep/1)
|> Enum.reject(&is_nil/1)

{:ok, components}

_ ->
{:error, :unresolved_dependency}
end
end

defp ok?(dep) do
Mix.Dep.ok?(dep) || Mix.Dep.compilable?(dep)
end

defp component_from_dep(%{opts: opts} = dep) do
case Map.new(opts) do
%{optional: true} ->
# If the dependency is optional at the top level, then we don't include
# it in the SBoM
nil

opts_map ->
component_from_dep(dep, opts_map)
end
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
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
end

defp component_from_dep(_dep, _opts), do: nil
end
76 changes: 76 additions & 0 deletions lib/sbom/cyclonedx.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
defmodule SBoM.CycloneDX do
@moduledoc false

alias SBoM.License

def bom(components, serial \\ nil)

def bom(components, nil) do
bom(components, uuid())
end

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

: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)}
]}
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)}
]}
end

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(8)
]
|> Enum.map(&Base.encode16(&1, case: :lower))
|> Enum.join("-")
end
end
Loading

0 comments on commit 221dae4

Please sign in to comment.