From 221dae45ca712841bf55413e9e74777ac47b7739 Mon Sep 17 00:00:00 2001 From: Bram Verburg Date: Thu, 24 Oct 2019 17:22:49 +0300 Subject: [PATCH] First commit --- .formatter.exs | 4 + .gitignore | 30 ++ LICENSE | 27 ++ README.md | 49 ++++ lib/mix/tasks/sbom.cyclonedx.ex | 72 +++++ lib/sbom.ex | 98 +++++++ lib/sbom/cyclonedx.ex | 76 +++++ lib/sbom/license.ex | 424 +++++++++++++++++++++++++++ lib/sbom/purl.ex | 76 +++++ mix.exs | 54 ++++ mix.lock | 7 + test/fixtures/sample1/mix.exs | 30 ++ test/fixtures/sample1/mix.lock | 17 ++ test/fixtures/with_path_dep/mix.exs | 28 ++ test/fixtures/with_path_dep/mix.lock | 11 + test/sbom/cyclonedx_test.exs | 89 ++++++ test/sbom/license_test.exs | 21 ++ test/sbom/purl_test.exs | 66 +++++ test/sbom_test.exs | 60 ++++ test/test_helper.exs | 1 + 20 files changed, 1240 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lib/mix/tasks/sbom.cyclonedx.ex create mode 100644 lib/sbom.ex create mode 100644 lib/sbom/cyclonedx.ex create mode 100644 lib/sbom/license.ex create mode 100644 lib/sbom/purl.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/fixtures/sample1/mix.exs create mode 100644 test/fixtures/sample1/mix.lock create mode 100644 test/fixtures/with_path_dep/mix.exs create mode 100644 test/fixtures/with_path_dep/mix.lock create mode 100644 test/sbom/cyclonedx_test.exs create mode 100644 test/sbom/license_test.exs create mode 100644 test/sbom/purl_test.exs create mode 100644 test/sbom_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..7f59b1f --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..807dd2d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0fced40 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f601e92 --- /dev/null +++ b/README.md @@ -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. diff --git a/lib/mix/tasks/sbom.cyclonedx.ex b/lib/mix/tasks/sbom.cyclonedx.ex new file mode 100644 index 0000000..65bda7b --- /dev/null +++ b/lib/mix/tasks/sbom.cyclonedx.ex @@ -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 diff --git a/lib/sbom.ex b/lib/sbom.ex new file mode 100644 index 0000000..80cf72e --- /dev/null +++ b/lib/sbom.ex @@ -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 diff --git a/lib/sbom/cyclonedx.ex b/lib/sbom/cyclonedx.ex new file mode 100644 index 0000000..9449d09 --- /dev/null +++ b/lib/sbom/cyclonedx.ex @@ -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 diff --git a/lib/sbom/license.ex b/lib/sbom/license.ex new file mode 100644 index 0000000..e5054d1 --- /dev/null +++ b/lib/sbom/license.ex @@ -0,0 +1,424 @@ +defmodule SBoM.License do + @moduledoc false + + @spdx_id %{ + "0bsd" => "0BSD", + "aal" => "AAL", + "adsl" => "ADSL", + "afl-1.1" => "AFL-1.1", + "afl-1.2" => "AFL-1.2", + "afl-2.0" => "AFL-2.0", + "afl-2.1" => "AFL-2.1", + "afl-3.0" => "AFL-3.0", + "agpl-1.0" => "AGPL-1.0", + "agpl-1.0-only" => "AGPL-1.0-only", + "agpl-1.0-or-later" => "AGPL-1.0-or-later", + "agpl-3.0" => "AGPL-3.0", + "agpl-3.0-only" => "AGPL-3.0-only", + "agpl-3.0-or-later" => "AGPL-3.0-or-later", + "amdplpa" => "AMDPLPA", + "aml" => "AML", + "ampas" => "AMPAS", + "antlr-pd" => "ANTLR-PD", + "apafml" => "APAFML", + "apl-1.0" => "APL-1.0", + "apsl-1.0" => "APSL-1.0", + "apsl-1.1" => "APSL-1.1", + "apsl-1.2" => "APSL-1.2", + "apsl-2.0" => "APSL-2.0", + "abstyles" => "Abstyles", + "adobe-2006" => "Adobe-2006", + "adobe-glyph" => "Adobe-Glyph", + "afmparse" => "Afmparse", + "aladdin" => "Aladdin", + "apache-1.0" => "Apache-1.0", + "apache-1.1" => "Apache-1.1", + "apache-2.0" => "Apache-2.0", + "artistic-1.0" => "Artistic-1.0", + "artistic-1.0-perl" => "Artistic-1.0-Perl", + "artistic-1.0-cl8" => "Artistic-1.0-cl8", + "artistic-2.0" => "Artistic-2.0", + "bsd-1-clause" => "BSD-1-Clause", + "bsd-2-clause" => "BSD-2-Clause", + "bsd-2-clause-freebsd" => "BSD-2-Clause-FreeBSD", + "bsd-2-clause-netbsd" => "BSD-2-Clause-NetBSD", + "bsd-2-clause-patent" => "BSD-2-Clause-Patent", + "bsd-3-clause" => "BSD-3-Clause", + "bsd-3-clause-attribution" => "BSD-3-Clause-Attribution", + "bsd-3-clause-clear" => "BSD-3-Clause-Clear", + "bsd-3-clause-lbnl" => "BSD-3-Clause-LBNL", + "bsd-3-clause-no-nuclear-license" => "BSD-3-Clause-No-Nuclear-License", + "bsd-3-clause-no-nuclear-license-2014" => "BSD-3-Clause-No-Nuclear-License-2014", + "bsd-3-clause-no-nuclear-warranty" => "BSD-3-Clause-No-Nuclear-Warranty", + "bsd-3-clause-open-mpi" => "BSD-3-Clause-Open-MPI", + "bsd-4-clause" => "BSD-4-Clause", + "bsd-4-clause-uc" => "BSD-4-Clause-UC", + "bsd-protection" => "BSD-Protection", + "bsd-source-code" => "BSD-Source-Code", + "bsl-1.0" => "BSL-1.0", + "bahyph" => "Bahyph", + "barr" => "Barr", + "beerware" => "Beerware", + "bittorrent-1.0" => "BitTorrent-1.0", + "bittorrent-1.1" => "BitTorrent-1.1", + "blueoak-1.0.0" => "BlueOak-1.0.0", + "borceux" => "Borceux", + "catosl-1.1" => "CATOSL-1.1", + "cc-by-1.0" => "CC-BY-1.0", + "cc-by-2.0" => "CC-BY-2.0", + "cc-by-2.5" => "CC-BY-2.5", + "cc-by-3.0" => "CC-BY-3.0", + "cc-by-4.0" => "CC-BY-4.0", + "cc-by-nc-1.0" => "CC-BY-NC-1.0", + "cc-by-nc-2.0" => "CC-BY-NC-2.0", + "cc-by-nc-2.5" => "CC-BY-NC-2.5", + "cc-by-nc-3.0" => "CC-BY-NC-3.0", + "cc-by-nc-4.0" => "CC-BY-NC-4.0", + "cc-by-nc-nd-1.0" => "CC-BY-NC-ND-1.0", + "cc-by-nc-nd-2.0" => "CC-BY-NC-ND-2.0", + "cc-by-nc-nd-2.5" => "CC-BY-NC-ND-2.5", + "cc-by-nc-nd-3.0" => "CC-BY-NC-ND-3.0", + "cc-by-nc-nd-4.0" => "CC-BY-NC-ND-4.0", + "cc-by-nc-sa-1.0" => "CC-BY-NC-SA-1.0", + "cc-by-nc-sa-2.0" => "CC-BY-NC-SA-2.0", + "cc-by-nc-sa-2.5" => "CC-BY-NC-SA-2.5", + "cc-by-nc-sa-3.0" => "CC-BY-NC-SA-3.0", + "cc-by-nc-sa-4.0" => "CC-BY-NC-SA-4.0", + "cc-by-nd-1.0" => "CC-BY-ND-1.0", + "cc-by-nd-2.0" => "CC-BY-ND-2.0", + "cc-by-nd-2.5" => "CC-BY-ND-2.5", + "cc-by-nd-3.0" => "CC-BY-ND-3.0", + "cc-by-nd-4.0" => "CC-BY-ND-4.0", + "cc-by-sa-1.0" => "CC-BY-SA-1.0", + "cc-by-sa-2.0" => "CC-BY-SA-2.0", + "cc-by-sa-2.5" => "CC-BY-SA-2.5", + "cc-by-sa-3.0" => "CC-BY-SA-3.0", + "cc-by-sa-4.0" => "CC-BY-SA-4.0", + "cc-pddc" => "CC-PDDC", + "cc0-1.0" => "CC0-1.0", + "cddl-1.0" => "CDDL-1.0", + "cddl-1.1" => "CDDL-1.1", + "cdla-permissive-1.0" => "CDLA-Permissive-1.0", + "cdla-sharing-1.0" => "CDLA-Sharing-1.0", + "cecill-1.0" => "CECILL-1.0", + "cecill-1.1" => "CECILL-1.1", + "cecill-2.0" => "CECILL-2.0", + "cecill-2.1" => "CECILL-2.1", + "cecill-b" => "CECILL-B", + "cecill-c" => "CECILL-C", + "cern-ohl-1.1" => "CERN-OHL-1.1", + "cern-ohl-1.2" => "CERN-OHL-1.2", + "cnri-jython" => "CNRI-Jython", + "cnri-python" => "CNRI-Python", + "cnri-python-gpl-compatible" => "CNRI-Python-GPL-Compatible", + "cpal-1.0" => "CPAL-1.0", + "cpl-1.0" => "CPL-1.0", + "cpol-1.02" => "CPOL-1.02", + "cua-opl-1.0" => "CUA-OPL-1.0", + "caldera" => "Caldera", + "clartistic" => "ClArtistic", + "condor-1.1" => "Condor-1.1", + "crossword" => "Crossword", + "crystalstacker" => "CrystalStacker", + "cube" => "Cube", + "d-fsl-1.0" => "D-FSL-1.0", + "doc" => "DOC", + "dsdp" => "DSDP", + "dotseqn" => "Dotseqn", + "ecl-1.0" => "ECL-1.0", + "ecl-2.0" => "ECL-2.0", + "efl-1.0" => "EFL-1.0", + "efl-2.0" => "EFL-2.0", + "epl-1.0" => "EPL-1.0", + "epl-2.0" => "EPL-2.0", + "eudatagrid" => "EUDatagrid", + "eupl-1.0" => "EUPL-1.0", + "eupl-1.1" => "EUPL-1.1", + "eupl-1.2" => "EUPL-1.2", + "entessa" => "Entessa", + "erlpl-1.1" => "ErlPL-1.1", + "eurosym" => "Eurosym", + "fsfap" => "FSFAP", + "fsful" => "FSFUL", + "fsfullr" => "FSFULLR", + "ftl" => "FTL", + "fair" => "Fair", + "frameworx-1.0" => "Frameworx-1.0", + "freeimage" => "FreeImage", + "gfdl-1.1" => "GFDL-1.1", + "gfdl-1.1-only" => "GFDL-1.1-only", + "gfdl-1.1-or-later" => "GFDL-1.1-or-later", + "gfdl-1.2" => "GFDL-1.2", + "gfdl-1.2-only" => "GFDL-1.2-only", + "gfdl-1.2-or-later" => "GFDL-1.2-or-later", + "gfdl-1.3" => "GFDL-1.3", + "gfdl-1.3-only" => "GFDL-1.3-only", + "gfdl-1.3-or-later" => "GFDL-1.3-or-later", + "gl2ps" => "GL2PS", + "gpl-1.0" => "GPL-1.0", + "gpl-1.0+" => "GPL-1.0+", + "gpl-1.0-only" => "GPL-1.0-only", + "gpl-1.0-or-later" => "GPL-1.0-or-later", + "gpl-2.0" => "GPL-2.0", + "gpl-2.0+" => "GPL-2.0+", + "gpl-2.0-only" => "GPL-2.0-only", + "gpl-2.0-or-later" => "GPL-2.0-or-later", + "gpl-2.0-with-gcc-exception" => "GPL-2.0-with-GCC-exception", + "gpl-2.0-with-autoconf-exception" => "GPL-2.0-with-autoconf-exception", + "gpl-2.0-with-bison-exception" => "GPL-2.0-with-bison-exception", + "gpl-2.0-with-classpath-exception" => "GPL-2.0-with-classpath-exception", + "gpl-2.0-with-font-exception" => "GPL-2.0-with-font-exception", + "gpl-3.0" => "GPL-3.0", + "gpl-3.0+" => "GPL-3.0+", + "gpl-3.0-only" => "GPL-3.0-only", + "gpl-3.0-or-later" => "GPL-3.0-or-later", + "gpl-3.0-with-gcc-exception" => "GPL-3.0-with-GCC-exception", + "gpl-3.0-with-autoconf-exception" => "GPL-3.0-with-autoconf-exception", + "giftware" => "Giftware", + "glide" => "Glide", + "glulxe" => "Glulxe", + "hpnd" => "HPND", + "hpnd-sell-variant" => "HPND-sell-variant", + "haskellreport" => "HaskellReport", + "ibm-pibs" => "IBM-pibs", + "icu" => "ICU", + "ijg" => "IJG", + "ipa" => "IPA", + "ipl-1.0" => "IPL-1.0", + "isc" => "ISC", + "imagemagick" => "ImageMagick", + "imlib2" => "Imlib2", + "info-zip" => "Info-ZIP", + "intel" => "Intel", + "intel-acpi" => "Intel-ACPI", + "interbase-1.0" => "Interbase-1.0", + "jpnic" => "JPNIC", + "json" => "JSON", + "jasper-2.0" => "JasPer-2.0", + "lal-1.2" => "LAL-1.2", + "lal-1.3" => "LAL-1.3", + "lgpl-2.0" => "LGPL-2.0", + "lgpl-2.0+" => "LGPL-2.0+", + "lgpl-2.0-only" => "LGPL-2.0-only", + "lgpl-2.0-or-later" => "LGPL-2.0-or-later", + "lgpl-2.1" => "LGPL-2.1", + "lgpl-2.1+" => "LGPL-2.1+", + "lgpl-2.1-only" => "LGPL-2.1-only", + "lgpl-2.1-or-later" => "LGPL-2.1-or-later", + "lgpl-3.0" => "LGPL-3.0", + "lgpl-3.0+" => "LGPL-3.0+", + "lgpl-3.0-only" => "LGPL-3.0-only", + "lgpl-3.0-or-later" => "LGPL-3.0-or-later", + "lgpllr" => "LGPLLR", + "lpl-1.0" => "LPL-1.0", + "lpl-1.02" => "LPL-1.02", + "lppl-1.0" => "LPPL-1.0", + "lppl-1.1" => "LPPL-1.1", + "lppl-1.2" => "LPPL-1.2", + "lppl-1.3a" => "LPPL-1.3a", + "lppl-1.3c" => "LPPL-1.3c", + "latex2e" => "Latex2e", + "leptonica" => "Leptonica", + "liliq-p-1.1" => "LiLiQ-P-1.1", + "liliq-r-1.1" => "LiLiQ-R-1.1", + "liliq-rplus-1.1" => "LiLiQ-Rplus-1.1", + "libpng" => "Libpng", + "linux-openib" => "Linux-OpenIB", + "mit" => "MIT", + "mit-0" => "MIT-0", + "mit-cmu" => "MIT-CMU", + "mit-advertising" => "MIT-advertising", + "mit-enna" => "MIT-enna", + "mit-feh" => "MIT-feh", + "mitnfa" => "MITNFA", + "mpl-1.0" => "MPL-1.0", + "mpl-1.1" => "MPL-1.1", + "mpl-2.0" => "MPL-2.0", + "mpl-2.0-no-copyleft-exception" => "MPL-2.0-no-copyleft-exception", + "ms-pl" => "MS-PL", + "ms-rl" => "MS-RL", + "mtll" => "MTLL", + "makeindex" => "MakeIndex", + "miros" => "MirOS", + "motosoto" => "Motosoto", + "multics" => "Multics", + "mup" => "Mup", + "nasa-1.3" => "NASA-1.3", + "nbpl-1.0" => "NBPL-1.0", + "ncsa" => "NCSA", + "ngpl" => "NGPL", + "nlod-1.0" => "NLOD-1.0", + "nlpl" => "NLPL", + "nosl" => "NOSL", + "npl-1.0" => "NPL-1.0", + "npl-1.1" => "NPL-1.1", + "nposl-3.0" => "NPOSL-3.0", + "nrl" => "NRL", + "ntp" => "NTP", + "naumen" => "Naumen", + "net-snmp" => "Net-SNMP", + "netcdf" => "NetCDF", + "newsletr" => "Newsletr", + "nokia" => "Nokia", + "noweb" => "Noweb", + "nunit" => "Nunit", + "occt-pl" => "OCCT-PL", + "oclc-2.0" => "OCLC-2.0", + "odc-by-1.0" => "ODC-By-1.0", + "odbl-1.0" => "ODbL-1.0", + "ofl-1.0" => "OFL-1.0", + "ofl-1.1" => "OFL-1.1", + "ogl-uk-1.0" => "OGL-UK-1.0", + "ogl-uk-2.0" => "OGL-UK-2.0", + "ogl-uk-3.0" => "OGL-UK-3.0", + "ogtsl" => "OGTSL", + "oldap-1.1" => "OLDAP-1.1", + "oldap-1.2" => "OLDAP-1.2", + "oldap-1.3" => "OLDAP-1.3", + "oldap-1.4" => "OLDAP-1.4", + "oldap-2.0" => "OLDAP-2.0", + "oldap-2.0.1" => "OLDAP-2.0.1", + "oldap-2.1" => "OLDAP-2.1", + "oldap-2.2" => "OLDAP-2.2", + "oldap-2.2.1" => "OLDAP-2.2.1", + "oldap-2.2.2" => "OLDAP-2.2.2", + "oldap-2.3" => "OLDAP-2.3", + "oldap-2.4" => "OLDAP-2.4", + "oldap-2.5" => "OLDAP-2.5", + "oldap-2.6" => "OLDAP-2.6", + "oldap-2.7" => "OLDAP-2.7", + "oldap-2.8" => "OLDAP-2.8", + "oml" => "OML", + "opl-1.0" => "OPL-1.0", + "oset-pl-2.1" => "OSET-PL-2.1", + "osl-1.0" => "OSL-1.0", + "osl-1.1" => "OSL-1.1", + "osl-2.0" => "OSL-2.0", + "osl-2.1" => "OSL-2.1", + "osl-3.0" => "OSL-3.0", + "openssl" => "OpenSSL", + "pddl-1.0" => "PDDL-1.0", + "php-3.0" => "PHP-3.0", + "php-3.01" => "PHP-3.01", + "parity-6.0.0" => "Parity-6.0.0", + "plexus" => "Plexus", + "postgresql" => "PostgreSQL", + "python-2.0" => "Python-2.0", + "qpl-1.0" => "QPL-1.0", + "qhull" => "Qhull", + "rhecos-1.1" => "RHeCos-1.1", + "rpl-1.1" => "RPL-1.1", + "rpl-1.5" => "RPL-1.5", + "rpsl-1.0" => "RPSL-1.0", + "rsa-md" => "RSA-MD", + "rscpl" => "RSCPL", + "rdisc" => "Rdisc", + "ruby" => "Ruby", + "sax-pd" => "SAX-PD", + "scea" => "SCEA", + "sgi-b-1.0" => "SGI-B-1.0", + "sgi-b-1.1" => "SGI-B-1.1", + "sgi-b-2.0" => "SGI-B-2.0", + "shl-0.5" => "SHL-0.5", + "shl-0.51" => "SHL-0.51", + "sissl" => "SISSL", + "sissl-1.2" => "SISSL-1.2", + "smlnj" => "SMLNJ", + "smppl" => "SMPPL", + "snia" => "SNIA", + "spl-1.0" => "SPL-1.0", + "ssh-openssh" => "SSH-OpenSSH", + "ssh-short" => "SSH-short", + "sspl-1.0" => "SSPL-1.0", + "swl" => "SWL", + "saxpath" => "Saxpath", + "sendmail" => "Sendmail", + "sendmail-8.23" => "Sendmail-8.23", + "simpl-2.0" => "SimPL-2.0", + "sleepycat" => "Sleepycat", + "spencer-86" => "Spencer-86", + "spencer-94" => "Spencer-94", + "spencer-99" => "Spencer-99", + "standardml-nj" => "StandardML-NJ", + "sugarcrm-1.1.3" => "SugarCRM-1.1.3", + "tapr-ohl-1.0" => "TAPR-OHL-1.0", + "tcl" => "TCL", + "tcp-wrappers" => "TCP-wrappers", + "tmate" => "TMate", + "torque-1.1" => "TORQUE-1.1", + "tosl" => "TOSL", + "tu-berlin-1.0" => "TU-Berlin-1.0", + "tu-berlin-2.0" => "TU-Berlin-2.0", + "ucl-1.0" => "UCL-1.0", + "upl-1.0" => "UPL-1.0", + "unicode-dfs-2015" => "Unicode-DFS-2015", + "unicode-dfs-2016" => "Unicode-DFS-2016", + "unicode-tou" => "Unicode-TOU", + "unlicense" => "Unlicense", + "vostrom" => "VOSTROM", + "vsl-1.0" => "VSL-1.0", + "vim" => "Vim", + "w3c" => "W3C", + "w3c-19980720" => "W3C-19980720", + "w3c-20150513" => "W3C-20150513", + "wtfpl" => "WTFPL", + "watcom-1.0" => "Watcom-1.0", + "wsuipa" => "Wsuipa", + "x11" => "X11", + "xfree86-1.1" => "XFree86-1.1", + "xskat" => "XSkat", + "xerox" => "Xerox", + "xnet" => "Xnet", + "ypl-1.0" => "YPL-1.0", + "ypl-1.1" => "YPL-1.1", + "zpl-1.1" => "ZPL-1.1", + "zpl-2.0" => "ZPL-2.0", + "zpl-2.1" => "ZPL-2.1", + "zed" => "Zed", + "zend-2.0" => "Zend-2.0", + "zimbra-1.3" => "Zimbra-1.3", + "zimbra-1.4" => "Zimbra-1.4", + "zlib" => "Zlib", + "blessing" => "blessing", + "bzip2-1.0.5" => "bzip2-1.0.5", + "bzip2-1.0.6" => "bzip2-1.0.6", + "copyleft-next-0.3.0" => "copyleft-next-0.3.0", + "copyleft-next-0.3.1" => "copyleft-next-0.3.1", + "curl" => "curl", + "diffmark" => "diffmark", + "dvipdfm" => "dvipdfm", + "ecos-2.0" => "eCos-2.0", + "egenix" => "eGenix", + "etalab-2.0" => "etalab-2.0", + "gsoap-1.3b" => "gSOAP-1.3b", + "gnuplot" => "gnuplot", + "imatix" => "iMatix", + "libpng-2.0" => "libpng-2.0", + "libtiff" => "libtiff", + "mpich2" => "mpich2", + "psfrag" => "psfrag", + "psutils" => "psutils", + "wxwindows" => "wxWindows", + "xinetd" => "xinetd", + "xpp" => "xpp", + "zlib-acknowledgement" => "zlib-acknowledgement" + } + + def spdx_id(id) do + @spdx_id[normalize(id)] + end + + defp normalize(s) do + s + |> String.downcase() + |> String.replace(~r/[ ,]+/, "-") + |> fixup() + end + + defp fixup("apache-2"), do: "apache-2.0" + defp fixup("apache-license-2.0"), do: "apache-2.0" + defp fixup("bsd-3"), do: "bsd-3-clause" + defp fixup("mit-license"), do: "mit" + defp fixup("mozilla-public-license-version-2.0"), do: "mpl-2.0" + defp fixup(id), do: id +end diff --git a/lib/sbom/purl.ex b/lib/sbom/purl.ex new file mode 100644 index 0000000..3ea8d20 --- /dev/null +++ b/lib/sbom/purl.ex @@ -0,0 +1,76 @@ +defmodule SBoM.Purl do + @moduledoc false + + # https://github.com/package-url/purl-spec + + def hex(name, version, repo \\ "hexpm") do + do_hex(String.downcase(name), version, String.downcase(repo)) + end + + defp do_hex(name, version, "hexpm") do + purl(["hex", name], version) + end + + defp do_hex(name, version, "hexpm:" <> organization) do + purl(["hex", organization, name], version) + end + + defp do_hex(name, version, repo) do + case Hex.Repo.fetch_repo(repo) do + {:ok, %{url: url}} -> + purl(["hex", name], version, repository_url: url) + + :error -> + raise "Undefined Hex repo: #{repo}" + end + end + + def git(_name, "git@github.com:" <> github, commit_or_tag) do + github |> String.replace_suffix(".git", "") |> github(commit_or_tag) + end + + def git(_name, "https://github.com/" <> github, commit_or_tag) do + github |> String.replace_suffix(".git", "") |> github(commit_or_tag) + end + + def git(_name, "git@bitbucket.org:" <> bitbucket, commit_or_tag) do + bitbucket |> String.replace_suffix(".git", "") |> bitbucket(commit_or_tag) + end + + def git(_name, "https://bitbucket.org/" <> bitbucket, commit_or_tag) do + bitbucket |> String.replace_suffix(".git", "") |> bitbucket(commit_or_tag) + end + + # Git dependence other than GitHub and BitBucket are not currently supported + def git(_name, _git, _commit_or_tag), do: nil + + def github(github, commit_or_tag) do + [organization, repository | _] = String.split(github, "/") + name = repository |> String.downcase() + purl(["github", String.downcase(organization), name], commit_or_tag) + end + + def bitbucket(bitbucket, commit_or_tag) do + [organization, repository | _] = String.split(bitbucket, "/") + name = repository |> String.downcase() + purl(["bitbucket", String.downcase(organization), name], commit_or_tag) + end + + defp purl(type_namespace_name, version, qualifiers \\ []) do + path = + type_namespace_name + |> Enum.map(&URI.encode/1) + |> Enum.join("/") + + %URI{ + scheme: "pkg", + path: "#{path}@#{URI.encode(version)}", + query: + case URI.encode_query(qualifiers) do + "" -> nil + query -> query + end + } + |> to_string() + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..f556fd3 --- /dev/null +++ b/mix.exs @@ -0,0 +1,54 @@ +defmodule SBoM.MixProject do + use Mix.Project + + @version "0.5.0" + + def project do + [ + app: :sbom, + version: @version, + elixir: "~> 1.7", + start_permanent: Mix.env() == :prod, + deps: deps(), + name: "SBoM", + description: description(), + package: package(), + docs: docs(), + source_url: "https://github.com/voltone/sbom" + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:hex] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ex_doc, "~> 0.21", only: :dev} + ] + end + + defp description do + "Mix task to generate a Software Bill-of-Materials (SBoM) in CycloneDX format" + end + + defp package do + [ + maintainers: ["Bram Verburg"], + licenses: ["BSD-3-Clause"], + links: %{"GitHub" => "https://github.com/voltone/sbom"} + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md"], + source_ref: "v#{@version}" + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..562bbd4 --- /dev/null +++ b/mix.lock @@ -0,0 +1,7 @@ +%{ + "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"}, + "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"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, +} diff --git a/test/fixtures/sample1/mix.exs b/test/fixtures/sample1/mix.exs new file mode 100644 index 0000000..c1f21d6 --- /dev/null +++ b/test/fixtures/sample1/mix.exs @@ -0,0 +1,30 @@ +defmodule Sample1.MixProject do + use Mix.Project + + def project do + [ + app: :sample1, + version: "0.1.0", + elixir: "~> 1.8", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:hackney, "~> 1.15"}, + {:jason, "~> 1.1", optional: true}, + {:ex_doc, "~> 0.21.2", only: :dev}, + {:meck, "~> 0.8.13", only: :test} + ] + end +end diff --git a/test/fixtures/sample1/mix.lock b/test/fixtures/sample1/mix.lock new file mode 100644 index 0000000..765aa57 --- /dev/null +++ b/test/fixtures/sample1/mix.lock @@ -0,0 +1,17 @@ +%{ + "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"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/test/fixtures/with_path_dep/mix.exs b/test/fixtures/with_path_dep/mix.exs new file mode 100644 index 0000000..ffdab1e --- /dev/null +++ b/test/fixtures/with_path_dep/mix.exs @@ -0,0 +1,28 @@ +defmodule WithPathDep.MixProject do + use Mix.Project + + def project do + [ + app: :with_path_dep, + version: "0.1.0", + elixir: "~> 1.8", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:sample1, path: "../sample1"}, + {:jason, "~> 1.1"} + ] + end +end diff --git a/test/fixtures/with_path_dep/mix.lock b/test/fixtures/with_path_dep/mix.lock new file mode 100644 index 0000000..3fe190c --- /dev/null +++ b/test/fixtures/with_path_dep/mix.lock @@ -0,0 +1,11 @@ +%{ + "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"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/test/sbom/cyclonedx_test.exs b/test/sbom/cyclonedx_test.exs new file mode 100644 index 0000000..c70cb2a --- /dev/null +++ b/test/sbom/cyclonedx_test.exs @@ -0,0 +1,89 @@ +defmodule SBoM.CycloneDXTest do + use ExUnit.Case + import SBoM.CycloneDX + + doctest 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]{16}") + 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) + assert xml =~ ~s() + 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) + end + end +end diff --git a/test/sbom/license_test.exs b/test/sbom/license_test.exs new file mode 100644 index 0000000..8829886 --- /dev/null +++ b/test/sbom/license_test.exs @@ -0,0 +1,21 @@ +defmodule SBoM.LicenseTest do + use ExUnit.Case + import SBoM.License + + doctest SBoM.License + + test :spdx_id do + assert "0BSD" = spdx_id("0BSD") + assert "MIT" = spdx_id("mit") + assert "BSD-3-Clause" = spdx_id("BSD 3-clause") + assert "Apache-2.0" = spdx_id("APACHE-2.0") + assert is_nil(spdx_id("Some other license")) + # Fixups: + assert "Apache-2.0" = spdx_id("Apache 2") + assert "Apache-2.0" = spdx_id("Apache license 2.0") + assert "BSD-3-Clause" = spdx_id("BSD-3") + assert "MIT" = spdx_id("MIT license") + 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 +end diff --git a/test/sbom/purl_test.exs b/test/sbom/purl_test.exs new file mode 100644 index 0000000..138ac97 --- /dev/null +++ b/test/sbom/purl_test.exs @@ -0,0 +1,66 @@ +defmodule SBoM.PurlTest do + use ExUnit.Case + import SBoM.Purl + + doctest SBoM.Purl + + setup_all do + repos = + Hex.State.fetch!(:repos) + |> Map.put("myrepo", %{url: "https://myrepo.example.com"}) + + Hex.State.put(:repos, repos) + end + + test :hex do + assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2") + assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2", "hexpm") + assert "pkg:hex/acme/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:acme") + + assert "pkg:hex/jason@1.1.2" = hex("Jason", "1.1.2") + assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2", "HEXPM") + assert "pkg:hex/acme/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:Acme") + + assert "pkg:hex/jason@1.1.2%25" = hex("jason", "1.1.2%") + # Not a valid organization name for Hex, but that's not what we're + # testing here + assert "pkg:hex/acme%25/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:acme%") + + assert "pkg:hex/bar@1.2.3?repository_url=https%3A%2F%2Fmyrepo.example.com" = + hex("bar", "1.2.3", "myrepo") + end + + test :git do + assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = + git("package-url", "https://github.com/package-url/purl-spec.git", "244fd47e07d1004") + + assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = + git("package-url", "git@github.com:package-url/purl-spec.git", "244fd47e07d1004") + + assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = + git( + "pygments-main", + "https://bitbucket.org/birkenfeld/pygments-main.git", + "244fd47e07d1014f0aed9c" + ) + + assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = + git( + "pygments-main", + "git@bitbucket.org:birkenfeld/pygments-main.git", + "244fd47e07d1014f0aed9c" + ) + + assert is_nil(git("ignored", "git@internal.host:some/project", "deadbeef")) + end + + test :github do + assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = + github("package-url/purl-spec", "244fd47e07d1004") + end + + test :bitbucket do + assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = + bitbucket("birkenfeld/pygments-main", "244fd47e07d1014f0aed9c") + end +end diff --git a/test/sbom_test.exs b/test/sbom_test.exs new file mode 100644 index 0000000..cbb2690 --- /dev/null +++ b/test/sbom_test.exs @@ -0,0 +1,60 @@ +defmodule SBoMTest do + use ExUnit.Case + doctest SBoM + + setup_all do + Mix.shell(Mix.Shell.Process) + :ok + end + + setup do + Mix.Shell.Process.flush() + :ok + end + + 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"]) + assert {:error, :unresolved_dependency} = SBoM.components_for_project() + + Mix.Task.run("deps.get") + assert {:ok, list} = SBoM.components_for_project() + assert length(list) == 8 + assert Enum.find(list, &match?(%{name: "hackney"}, &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 Enum.find(list, &match?(%{name: "hackney"}, &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)) + 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"]) + assert {:error, :unresolved_dependency} = SBoM.components_for_project() + + Mix.Task.run("deps.get") + assert {:ok, list} = SBoM.components_for_project() + assert length(list) == 9 + assert Enum.find(list, &match?(%{name: "hackney"}, &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 Enum.find(list, &match?(%{name: "hackney"}, &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)) + end) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()