├── .formatter.exs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── mix │ └── tasks │ │ └── sbom.cyclonedx.ex ├── sbom.ex └── sbom │ ├── cpe.ex │ ├── cyclonedx.ex │ ├── license.ex │ └── purl.ex ├── mix.exs └── test ├── fixtures ├── sample1 │ ├── mix.exs │ └── mix.lock └── with_path_dep │ ├── mix.exs │ └── mix.lock ├── mix └── tasks │ └── sbom.cyclonedx_test.exs ├── sbom ├── cyclonedx_test.exs ├── license_test.exs └── purl_test.exs ├── sbom_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-24.04 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | matrix: 9 | otp: ['25.3', '26.2'] 10 | elixir: ['1.15.7', '1.16.3', '1.17.3'] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: erlef/setup-beam@v1 14 | with: 15 | otp-version: ${{matrix.otp}} 16 | elixir-version: ${{matrix.elixir}} 17 | - run: mix deps.get 18 | - run: mix test 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sbom-*.tar 24 | 25 | # Ignore SBoM 26 | bom.xml 27 | 28 | # Ignore dependency artifacts from test fixtures 29 | /test/fixtures/*/deps/ 30 | /test/fixtures/*/_build/ 31 | 32 | mix.lock 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Bram Verburg 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SBoM 2 | 3 | Generates a Software Bill-of-Materials (SBoM) for Mix projects, in [CycloneDX](https://cyclonedx.org) 4 | format. 5 | 6 | Full documentation can be found at [https://hexdocs.pm/sbom](https://hexdocs.pm/sbom). 7 | 8 | For a quick demo of how this might be used, check out [this blog post](https://blog.voltone.net/post/24). 9 | 10 | ## Installation 11 | 12 | To install the Mix task globally on your system, run `mix archive.install hex sbom`. 13 | 14 | Alternatively, the package can be added to a project's dependencies to make the 15 | Mix task available for that project only: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:sbom, "~> 0.6", only: :dev, runtime: false} 21 | ] 22 | end 23 | ``` 24 | 25 | ## Usage 26 | 27 | To produce a CycloneDX SBoM, run `mix sbom.cyclonedx` from the project 28 | directory. The result is written to a file named `bom.xml`, unless a different 29 | name is specified using the `-o` option. 30 | 31 | By default only the dependencies used in production are included. To include all 32 | dependencies, including those for the 'dev' and 'test' environments, pass the 33 | `-d` command line option: `mix sbom.cyclonedx -d`. 34 | 35 | *Note that MIX_ENV does not affect which dependencies are included in the 36 | output; the task should normally be run in the default (dev) environment* 37 | 38 | For more information on the command line arguments accepted by the Mix task 39 | run `mix help sbom.cyclonedx`. 40 | 41 | ## NPM packages and other dependencies 42 | 43 | This tool only considers Hex, GitHub and BitBucket dependencies managed through 44 | Mix. To build a comprehensive SBoM of a deployment, including NPM and/or 45 | operating system packages, it may be necessary to merge multiple CycloneDX files 46 | into one. 47 | 48 | The [@cyclonedx/bom](https://www.npmjs.com/package/@cyclonedx/bom) tool on NPM 49 | can not only generate an SBoM for your JavaScript assets, but it can also merge 50 | in the output of the 'sbom.cyclonedx' Mix task and other scanners, through the 51 | '-a' option, producing a single CycloneDX XML file. 52 | -------------------------------------------------------------------------------- /lib/mix/tasks/sbom.cyclonedx.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Sbom.Cyclonedx do 2 | @shortdoc "Generates CycloneDX SBoM" 3 | 4 | use Mix.Task 5 | import Mix.Generator 6 | 7 | @schema_versions ["1.6", "1.5", "1.4", "1.3", "1.2", "1.1"] 8 | 9 | @default_path "bom.xml" 10 | @default_path_json "bom.json" 11 | @default_schema "1.6" 12 | @default_classification "application" 13 | 14 | @default_opts [ 15 | schema: @default_schema, 16 | classification: @default_classification 17 | ] 18 | 19 | @moduledoc """ 20 | Generates a Software Bill-of-Materials (SBoM) in CycloneDX format. 21 | 22 | ## Options 23 | 24 | * `--output` (`-o`): the full path to the SBoM output file (default: 25 | #{@default_path}) 26 | * `--force` (`-f`): overwrite existing files without prompting for 27 | confirmation 28 | * `--dev` (`-d`): include dependencies for non-production environments 29 | (including `dev`, `test` or `docs`); by default only dependencies for 30 | MIX_ENV=prod are returned 31 | * `--recurse` (`-r`): in an umbrella project, generate individual output 32 | files for each application, rather than a single file for the entire 33 | project 34 | * `--schema` (`-s`): schema version to be used, defaults to 35 | "#{@default_schema}" 36 | * `--format` (`-t`): output format: xml or json; defaults to "xml", unless 37 | the output path ends with ".json" 38 | * `--classification` (`-c`): the project classification, e.g. "application", 39 | "library", "framework"; defaults to "#{@default_classification}" 40 | 41 | """ 42 | 43 | @doc false 44 | @impl Mix.Task 45 | def run(all_args) do 46 | {opts, _args} = 47 | OptionParser.parse!( 48 | all_args, 49 | aliases: [ 50 | o: :output, 51 | f: :force, 52 | d: :dev, 53 | r: :recurse, 54 | s: :schema, 55 | t: :format, 56 | c: :classification 57 | ], 58 | strict: [ 59 | output: :string, 60 | force: :boolean, 61 | dev: :boolean, 62 | recurse: :boolean, 63 | schema: :string, 64 | format: :string, 65 | classification: :string 66 | ] 67 | ) 68 | 69 | opts = 70 | @default_opts 71 | |> Keyword.merge(opts) 72 | |> update_output_path_and_format!() 73 | 74 | validate_schema!(opts[:schema]) 75 | 76 | output_path = opts[:output] 77 | environment = (!opts[:dev] && :prod) || nil 78 | apps = Mix.Project.apps_paths() 79 | 80 | if opts[:recurse] && apps do 81 | Enum.each(apps, &generate_bom(&1, output_path, environment, opts[:force])) 82 | else 83 | generate_bom(output_path, environment, opts) 84 | end 85 | end 86 | 87 | defp generate_bom(output_path, environment, opts) do 88 | classification = opts[:classification] 89 | 90 | case SBoM.components_for_project(classification, environment) do 91 | {:ok, components} -> 92 | xml = SBoM.CycloneDX.bom(components, opts) 93 | create_file(output_path, xml, force: opts[:force]) 94 | 95 | {:error, :unresolved_dependency} -> 96 | dependency_error() 97 | end 98 | end 99 | 100 | defp generate_bom({app, path}, output_path, environment, force) do 101 | Mix.Project.in_project(app, path, fn _module -> 102 | generate_bom(output_path, environment, force) 103 | end) 104 | end 105 | 106 | defp dependency_error do 107 | shell = Mix.shell() 108 | shell.error("Unchecked dependencies; please run `mix deps.get`") 109 | Mix.raise("Can't continue due to errors on dependencies") 110 | end 111 | 112 | defp update_output_path_and_format!(opts) do 113 | {output, format} = 114 | case {opts[:output], opts[:format]} do 115 | {nil, nil} -> 116 | {@default_path, format_from_path(@default_path)} 117 | 118 | {output, nil} -> 119 | {output, format_from_path(output)} 120 | 121 | {nil, "xml"} -> 122 | {@default_path, "xml"} 123 | 124 | {nil, "json"} -> 125 | {@default_path_json, "json"} 126 | 127 | {output, format} when format in ["xml", "json"] -> 128 | {output, format} 129 | 130 | {_, format} -> 131 | Mix.raise("Unsupported output format: #{format}") 132 | end 133 | 134 | Keyword.merge(opts, output: output, format: format) 135 | end 136 | 137 | defp format_from_path(path) do 138 | case Path.extname(path) do 139 | ".json" -> "json" 140 | _ -> "xml" 141 | end 142 | end 143 | 144 | defp validate_schema!(schema) do 145 | if schema not in @schema_versions do 146 | shell = Mix.shell() 147 | 148 | shell.error( 149 | "invalid cyclonedx schema version, available versions are #{@schema_versions |> Enum.join(", ")}" 150 | ) 151 | 152 | Mix.raise("Give correct cyclonedx schema version to continue.") 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/sbom.ex: -------------------------------------------------------------------------------- 1 | defmodule SBoM do 2 | @moduledoc """ 3 | Collect dependency information for use in a Software Bill-of-Materials (SBOM). 4 | """ 5 | 6 | alias SBoM.Purl 7 | alias SBoM.Cpe 8 | 9 | @tool_version Mix.Project.config()[:version] 10 | 11 | @doc """ 12 | Returns the version number of this tool. 13 | """ 14 | def tool_version, do: @tool_version 15 | 16 | @doc """ 17 | Builds a SBoM for the current Mix project. The result can be exported to 18 | CycloneDX XML format using the `SBoM.CycloneDX` module. Pass an environment 19 | of `nil` to include dependencies across all environments. 20 | 21 | Wrap the call to this function with `Mix.Project.in_project/3,4` to select a 22 | Mix project by path. 23 | """ 24 | def components_for_project(classification, environment \\ :prod) do 25 | Mix.Project.get!() 26 | 27 | {deps, not_ok} = 28 | load_env_deps(env: environment) 29 | |> Enum.split_with(&ok?/1) 30 | 31 | case not_ok do 32 | [] -> 33 | components = 34 | deps 35 | |> Enum.map(&component_from_dep/1) 36 | |> Enum.reject(&is_nil/1) 37 | 38 | project = 39 | component_from_project(Mix.Project.config(), classification) 40 | 41 | {:ok, [project | components]} 42 | 43 | _ -> 44 | {:error, :unresolved_dependency} 45 | end 46 | end 47 | 48 | if Version.match?(System.version(), ">= 1.16.0") do 49 | defp load_env_deps(options) do 50 | Mix.Dep.Converger.converge(options) 51 | end 52 | else 53 | defp load_env_deps(options) do 54 | # Removed in Elixir >= 1.16.0 55 | Mix.Dep.load_on_environment(options) 56 | end 57 | end 58 | 59 | defp ok?(dep) do 60 | Mix.Dep.ok?(dep) || Mix.Dep.compilable?(dep) 61 | end 62 | 63 | defp component_from_project(opts, type) do 64 | name = 65 | case opts[:app] do 66 | nil -> 67 | # For umbrella apps, when the `:app` property is not set, fall back 68 | # to the current working directory's name, for now 69 | File.cwd!() |> Path.basename() 70 | 71 | app -> 72 | to_string(app) 73 | end 74 | 75 | %{ 76 | type: type, 77 | name: name, 78 | version: opts[:version] 79 | } 80 | end 81 | 82 | defp component_from_dep(%{opts: opts} = dep) do 83 | case Map.new(opts) do 84 | %{optional: true} -> 85 | # If the dependency is optional at the top level, then we don't include 86 | # it in the SBoM 87 | nil 88 | 89 | opts_map -> 90 | component_from_dep(dep, opts_map) 91 | end 92 | end 93 | 94 | defp component_from_dep(%{scm: Hex.SCM}, opts) do 95 | %{hex: name, lock: lock, dest: dest} = opts 96 | version = elem(lock, 2) 97 | sha256 = elem(lock, 3) 98 | 99 | hex_metadata_path = Path.expand("hex_metadata.config", dest) 100 | 101 | metadata = 102 | case :file.consult(hex_metadata_path) do 103 | {:ok, metadata} -> metadata 104 | _ -> [] 105 | end 106 | 107 | {_, description} = List.keyfind(metadata, "description", 0, {"description", ""}) 108 | {_, licenses} = List.keyfind(metadata, "licenses", 0, {"licenses", []}) 109 | 110 | %{ 111 | type: "library", 112 | name: name, 113 | version: version, 114 | purl: Purl.hex(name, version, opts[:repo]), 115 | cpe: Cpe.hex(name, version, opts[:repo]), 116 | hashes: %{ 117 | "SHA-256" => sha256 118 | }, 119 | description: description, 120 | licenses: licenses 121 | } 122 | end 123 | 124 | defp component_from_dep(%{scm: Mix.SCM.Git, app: app}, opts) do 125 | %{git: git, lock: lock, dest: _dest} = opts 126 | 127 | version = 128 | case opts[:tag] do 129 | nil -> 130 | elem(lock, 2) 131 | 132 | tag -> 133 | tag 134 | end 135 | 136 | %{ 137 | type: "library", 138 | name: to_string(app), 139 | version: version, 140 | purl: Purl.git(to_string(app), git, version), 141 | licenses: [] 142 | } 143 | end 144 | 145 | defp component_from_dep(_dep, _opts), do: nil 146 | end 147 | -------------------------------------------------------------------------------- /lib/sbom/cpe.ex: -------------------------------------------------------------------------------- 1 | defmodule SBoM.Cpe do 2 | @moduledoc false 3 | 4 | def hex(name, version, repo \\ "hexpm") do 5 | do_hex(String.downcase(name), version, String.downcase(repo)) 6 | end 7 | 8 | defp do_hex("hex_core", version, "hexpm") do 9 | "cpe:2.3:a:hex:hex_core:#{version}:*:*:*:*:*:*:*" 10 | end 11 | 12 | defp do_hex("plug", version, "hexpm") do 13 | "cpe:2.3:a:elixir-plug:plug:#{version}:*:*:*:*:*:*:*" 14 | end 15 | 16 | defp do_hex("phoenix", version, "hexpm") do 17 | "cpe:2.3:a:phoenixframework:phoenix:#{version}:*:*:*:*:*:*:*" 18 | end 19 | 20 | defp do_hex("coherence", version, "hexpm") do 21 | "cpe:2.3:a:coherence_project:coherence:#{version}:*:*:*:*:*:*:*" 22 | end 23 | 24 | defp do_hex("xain", version, "hexpm") do 25 | "cpe:2.3:a:emetrotel:xain:#{version}:*:*:*:*:*:*:*" 26 | end 27 | 28 | defp do_hex("sweet_xml", version, "hexpm") do 29 | "cpe:2.3:a:kbrw:sweet_xml:#{version}:*:*:*:*:*:*:*" 30 | end 31 | 32 | defp do_hex(_name, _version, _repo), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /lib/sbom/cyclonedx.ex: -------------------------------------------------------------------------------- 1 | defmodule SBoM.CycloneDX do 2 | @moduledoc """ 3 | Generate a CycloneDX SBoM in XML format. 4 | """ 5 | 6 | alias SBoM.License 7 | 8 | @doc """ 9 | Generate a CycloneDX SBoM in XML or JSON format from the specified list of 10 | components. Returns an `iolist`, which may be written to a file or IO device, 11 | or converted to a String using `IO.iodata_to_binary/1` 12 | 13 | The first component in the list is assumed to be the top-level component 14 | that the SBoM describes. 15 | 16 | If no serial number is specified a random UUID is generated. 17 | """ 18 | def bom(components, options \\ []) do 19 | case {options[:format], options[:schema]} do 20 | {"xml", schema} -> 21 | doc = xml_bom(components, schema, options) 22 | :xmerl.export_simple([doc], :xmerl_xml) 23 | 24 | {"json", "1.1"} -> 25 | raise "Invalid schema version: CycloneDX 1.1 does not support JSON format" 26 | 27 | {"json", schema} -> 28 | case Code.ensure_loaded(:json) do 29 | {:error, :nofile} -> 30 | raise "JSON output requires OTP >=27" 31 | 32 | {:module, :json} -> 33 | doc = json_bom(components, schema, options) 34 | :json.encode(doc) 35 | end 36 | end 37 | end 38 | 39 | defp xml_bom([_top_level_component | components], "1.1" = schema, options) do 40 | {:bom, xml_bom_attributes(options, schema), 41 | [ 42 | xml_components(components, schema) 43 | ]} 44 | end 45 | 46 | defp xml_bom([top_level_component | components], schema, options) do 47 | {:bom, xml_bom_attributes(options, schema), 48 | [ 49 | xml_metadata(top_level_component, options, schema), 50 | xml_components(components, schema) 51 | ]} 52 | end 53 | 54 | defp xml_bom_attributes(options, schema) do 55 | [ 56 | serialNumber: options[:serial] || urn_uuid(), 57 | xmlns: "http://cyclonedx.org/schema/bom/#{schema}" 58 | ] 59 | end 60 | 61 | defp xml_metadata(component, _options, schema) do 62 | {:metadata, [], 63 | [ 64 | {:timestamp, [], [[DateTime.utc_now() |> DateTime.to_iso8601()]]}, 65 | {:tools, [], 66 | [ 67 | {:tool, [], 68 | [ 69 | name: [["SBoM Mix task for Elixir"]], 70 | version: [[SBoM.tool_version()]] 71 | ]} 72 | ]}, 73 | xml_component(component, schema) 74 | ]} 75 | end 76 | 77 | defp xml_components(components, schema) do 78 | {:components, [], Enum.map(components, &xml_component(&1, schema))} 79 | end 80 | 81 | defp xml_component(component, schema) do 82 | {:component, [type: component.type], xml_component_fields(component, schema)} 83 | end 84 | 85 | defp xml_component_fields(component, schema) do 86 | [:name, :version, :description, :hashes, :licenses, :cpe, :purl] 87 | |> Enum.map(&xml_component_field(&1, component[&1], schema)) 88 | |> Enum.reject(&is_nil/1) 89 | end 90 | 91 | @simple_fields [:name, :version, :purl, :cpe, :description] 92 | 93 | defp xml_component_field(field, value, _schema) 94 | when field in @simple_fields and not is_nil(value) do 95 | {field, [], [[value]]} 96 | end 97 | 98 | defp xml_component_field(:hashes, hashes, _schema) when is_map(hashes) do 99 | {:hashes, [], Enum.map(hashes, &xml_hash/1)} 100 | end 101 | 102 | defp xml_component_field(:licenses, [_ | _] = licenses, _schema) do 103 | {:licenses, [], Enum.map(licenses, &xml_license/1)} 104 | end 105 | 106 | defp xml_component_field(_field, _value, _schema), do: nil 107 | 108 | defp xml_license(name) do 109 | # If the name is a recognized SPDX license ID, or if we can turn it into 110 | # one, we return a bom:license with a bom:id element 111 | case License.spdx_id(name) do 112 | nil -> 113 | {:license, [], 114 | [ 115 | {:name, [], [[name]]} 116 | ]} 117 | 118 | id -> 119 | {:license, [], 120 | [ 121 | {:id, [], [[id]]} 122 | ]} 123 | end 124 | end 125 | 126 | defp xml_hash({algorithm, hash}) do 127 | {:hash, [alg: algorithm], [[hash]]} 128 | end 129 | 130 | defp json_bom([top_level_component | components], schema, options) do 131 | %{ 132 | bomFormat: "CycloneDX", 133 | specVersion: schema, 134 | serialNumber: options[:serial] || urn_uuid(), 135 | version: options[:version] || 1, 136 | metadata: json_metadata(top_level_component, schema, options), 137 | components: Enum.map(components, &json_component(&1, schema, options)) 138 | } 139 | end 140 | 141 | defp json_metadata(top_level_component, schema, options) do 142 | %{ 143 | timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), 144 | tools: [ 145 | %{ 146 | name: "SBoM Mix task for Elixir", 147 | version: SBoM.tool_version() 148 | } 149 | ], 150 | component: json_component(top_level_component, schema, options) 151 | } 152 | end 153 | 154 | defp json_component(component, schema, options) do 155 | [ 156 | type: component[:type], 157 | name: component[:name], 158 | version: component[:version], 159 | description: component[:description], 160 | hashes: json_hashes(component[:hashes], schema, options), 161 | licenses: json_licenses(component[:licenses], schema, options), 162 | purl: component[:purl], 163 | cpe: component[:cpe] 164 | ] 165 | |> Enum.reject(&is_nil(elem(&1, 1))) 166 | |> Enum.into(%{}) 167 | end 168 | 169 | defp json_hashes(nil, _schema, _options), do: nil 170 | 171 | defp json_hashes(hashes, _schema, _options) when is_map(hashes) do 172 | Enum.map(hashes, fn {alg, content} -> 173 | %{ 174 | alg: alg, 175 | content: content 176 | } 177 | end) 178 | end 179 | 180 | defp json_licenses(nil, _schema, _options), do: nil 181 | 182 | defp json_licenses(licenses, _schema, _options) when is_list(licenses) do 183 | Enum.map(licenses, fn name -> 184 | license = 185 | case License.spdx_id(name) do 186 | nil -> 187 | %{name: name} 188 | 189 | id -> 190 | %{id: id} 191 | end 192 | 193 | %{ 194 | license: license 195 | } 196 | end) 197 | end 198 | 199 | defp urn_uuid(), do: "urn:uuid:#{uuid()}" 200 | 201 | defp uuid() do 202 | [ 203 | :crypto.strong_rand_bytes(4), 204 | :crypto.strong_rand_bytes(2), 205 | <<4::4, :crypto.strong_rand_bytes(2)::binary-size(12)-unit(1)>>, 206 | <<2::2, :crypto.strong_rand_bytes(2)::binary-size(14)-unit(1)>>, 207 | :crypto.strong_rand_bytes(6) 208 | ] 209 | |> Enum.map(&Base.encode16(&1, case: :lower)) 210 | |> Enum.join("-") 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/sbom/license.ex: -------------------------------------------------------------------------------- 1 | defmodule SBoM.License do 2 | @moduledoc false 3 | 4 | @spdx_id %{ 5 | "0bsd" => "0BSD", 6 | "aal" => "AAL", 7 | "adsl" => "ADSL", 8 | "afl-1.1" => "AFL-1.1", 9 | "afl-1.2" => "AFL-1.2", 10 | "afl-2.0" => "AFL-2.0", 11 | "afl-2.1" => "AFL-2.1", 12 | "afl-3.0" => "AFL-3.0", 13 | "agpl-1.0" => "AGPL-1.0", 14 | "agpl-1.0-only" => "AGPL-1.0-only", 15 | "agpl-1.0-or-later" => "AGPL-1.0-or-later", 16 | "agpl-3.0" => "AGPL-3.0", 17 | "agpl-3.0-only" => "AGPL-3.0-only", 18 | "agpl-3.0-or-later" => "AGPL-3.0-or-later", 19 | "amdplpa" => "AMDPLPA", 20 | "aml" => "AML", 21 | "ampas" => "AMPAS", 22 | "antlr-pd" => "ANTLR-PD", 23 | "apafml" => "APAFML", 24 | "apl-1.0" => "APL-1.0", 25 | "apsl-1.0" => "APSL-1.0", 26 | "apsl-1.1" => "APSL-1.1", 27 | "apsl-1.2" => "APSL-1.2", 28 | "apsl-2.0" => "APSL-2.0", 29 | "abstyles" => "Abstyles", 30 | "adobe-2006" => "Adobe-2006", 31 | "adobe-glyph" => "Adobe-Glyph", 32 | "afmparse" => "Afmparse", 33 | "aladdin" => "Aladdin", 34 | "apache-1.0" => "Apache-1.0", 35 | "apache-1.1" => "Apache-1.1", 36 | "apache-2.0" => "Apache-2.0", 37 | "artistic-1.0" => "Artistic-1.0", 38 | "artistic-1.0-perl" => "Artistic-1.0-Perl", 39 | "artistic-1.0-cl8" => "Artistic-1.0-cl8", 40 | "artistic-2.0" => "Artistic-2.0", 41 | "bsd-1-clause" => "BSD-1-Clause", 42 | "bsd-2-clause" => "BSD-2-Clause", 43 | "bsd-2-clause-freebsd" => "BSD-2-Clause-FreeBSD", 44 | "bsd-2-clause-netbsd" => "BSD-2-Clause-NetBSD", 45 | "bsd-2-clause-patent" => "BSD-2-Clause-Patent", 46 | "bsd-3-clause" => "BSD-3-Clause", 47 | "bsd-3-clause-attribution" => "BSD-3-Clause-Attribution", 48 | "bsd-3-clause-clear" => "BSD-3-Clause-Clear", 49 | "bsd-3-clause-lbnl" => "BSD-3-Clause-LBNL", 50 | "bsd-3-clause-no-nuclear-license" => "BSD-3-Clause-No-Nuclear-License", 51 | "bsd-3-clause-no-nuclear-license-2014" => "BSD-3-Clause-No-Nuclear-License-2014", 52 | "bsd-3-clause-no-nuclear-warranty" => "BSD-3-Clause-No-Nuclear-Warranty", 53 | "bsd-3-clause-open-mpi" => "BSD-3-Clause-Open-MPI", 54 | "bsd-4-clause" => "BSD-4-Clause", 55 | "bsd-4-clause-uc" => "BSD-4-Clause-UC", 56 | "bsd-protection" => "BSD-Protection", 57 | "bsd-source-code" => "BSD-Source-Code", 58 | "bsl-1.0" => "BSL-1.0", 59 | "bahyph" => "Bahyph", 60 | "barr" => "Barr", 61 | "beerware" => "Beerware", 62 | "bittorrent-1.0" => "BitTorrent-1.0", 63 | "bittorrent-1.1" => "BitTorrent-1.1", 64 | "blueoak-1.0.0" => "BlueOak-1.0.0", 65 | "borceux" => "Borceux", 66 | "catosl-1.1" => "CATOSL-1.1", 67 | "cc-by-1.0" => "CC-BY-1.0", 68 | "cc-by-2.0" => "CC-BY-2.0", 69 | "cc-by-2.5" => "CC-BY-2.5", 70 | "cc-by-3.0" => "CC-BY-3.0", 71 | "cc-by-4.0" => "CC-BY-4.0", 72 | "cc-by-nc-1.0" => "CC-BY-NC-1.0", 73 | "cc-by-nc-2.0" => "CC-BY-NC-2.0", 74 | "cc-by-nc-2.5" => "CC-BY-NC-2.5", 75 | "cc-by-nc-3.0" => "CC-BY-NC-3.0", 76 | "cc-by-nc-4.0" => "CC-BY-NC-4.0", 77 | "cc-by-nc-nd-1.0" => "CC-BY-NC-ND-1.0", 78 | "cc-by-nc-nd-2.0" => "CC-BY-NC-ND-2.0", 79 | "cc-by-nc-nd-2.5" => "CC-BY-NC-ND-2.5", 80 | "cc-by-nc-nd-3.0" => "CC-BY-NC-ND-3.0", 81 | "cc-by-nc-nd-4.0" => "CC-BY-NC-ND-4.0", 82 | "cc-by-nc-sa-1.0" => "CC-BY-NC-SA-1.0", 83 | "cc-by-nc-sa-2.0" => "CC-BY-NC-SA-2.0", 84 | "cc-by-nc-sa-2.5" => "CC-BY-NC-SA-2.5", 85 | "cc-by-nc-sa-3.0" => "CC-BY-NC-SA-3.0", 86 | "cc-by-nc-sa-4.0" => "CC-BY-NC-SA-4.0", 87 | "cc-by-nd-1.0" => "CC-BY-ND-1.0", 88 | "cc-by-nd-2.0" => "CC-BY-ND-2.0", 89 | "cc-by-nd-2.5" => "CC-BY-ND-2.5", 90 | "cc-by-nd-3.0" => "CC-BY-ND-3.0", 91 | "cc-by-nd-4.0" => "CC-BY-ND-4.0", 92 | "cc-by-sa-1.0" => "CC-BY-SA-1.0", 93 | "cc-by-sa-2.0" => "CC-BY-SA-2.0", 94 | "cc-by-sa-2.5" => "CC-BY-SA-2.5", 95 | "cc-by-sa-3.0" => "CC-BY-SA-3.0", 96 | "cc-by-sa-4.0" => "CC-BY-SA-4.0", 97 | "cc-pddc" => "CC-PDDC", 98 | "cc0-1.0" => "CC0-1.0", 99 | "cddl-1.0" => "CDDL-1.0", 100 | "cddl-1.1" => "CDDL-1.1", 101 | "cdla-permissive-1.0" => "CDLA-Permissive-1.0", 102 | "cdla-sharing-1.0" => "CDLA-Sharing-1.0", 103 | "cecill-1.0" => "CECILL-1.0", 104 | "cecill-1.1" => "CECILL-1.1", 105 | "cecill-2.0" => "CECILL-2.0", 106 | "cecill-2.1" => "CECILL-2.1", 107 | "cecill-b" => "CECILL-B", 108 | "cecill-c" => "CECILL-C", 109 | "cern-ohl-1.1" => "CERN-OHL-1.1", 110 | "cern-ohl-1.2" => "CERN-OHL-1.2", 111 | "cnri-jython" => "CNRI-Jython", 112 | "cnri-python" => "CNRI-Python", 113 | "cnri-python-gpl-compatible" => "CNRI-Python-GPL-Compatible", 114 | "cpal-1.0" => "CPAL-1.0", 115 | "cpl-1.0" => "CPL-1.0", 116 | "cpol-1.02" => "CPOL-1.02", 117 | "cua-opl-1.0" => "CUA-OPL-1.0", 118 | "caldera" => "Caldera", 119 | "clartistic" => "ClArtistic", 120 | "condor-1.1" => "Condor-1.1", 121 | "crossword" => "Crossword", 122 | "crystalstacker" => "CrystalStacker", 123 | "cube" => "Cube", 124 | "d-fsl-1.0" => "D-FSL-1.0", 125 | "doc" => "DOC", 126 | "dsdp" => "DSDP", 127 | "dotseqn" => "Dotseqn", 128 | "ecl-1.0" => "ECL-1.0", 129 | "ecl-2.0" => "ECL-2.0", 130 | "efl-1.0" => "EFL-1.0", 131 | "efl-2.0" => "EFL-2.0", 132 | "epl-1.0" => "EPL-1.0", 133 | "epl-2.0" => "EPL-2.0", 134 | "eudatagrid" => "EUDatagrid", 135 | "eupl-1.0" => "EUPL-1.0", 136 | "eupl-1.1" => "EUPL-1.1", 137 | "eupl-1.2" => "EUPL-1.2", 138 | "entessa" => "Entessa", 139 | "erlpl-1.1" => "ErlPL-1.1", 140 | "eurosym" => "Eurosym", 141 | "fsfap" => "FSFAP", 142 | "fsful" => "FSFUL", 143 | "fsfullr" => "FSFULLR", 144 | "ftl" => "FTL", 145 | "fair" => "Fair", 146 | "frameworx-1.0" => "Frameworx-1.0", 147 | "freeimage" => "FreeImage", 148 | "gfdl-1.1" => "GFDL-1.1", 149 | "gfdl-1.1-only" => "GFDL-1.1-only", 150 | "gfdl-1.1-or-later" => "GFDL-1.1-or-later", 151 | "gfdl-1.2" => "GFDL-1.2", 152 | "gfdl-1.2-only" => "GFDL-1.2-only", 153 | "gfdl-1.2-or-later" => "GFDL-1.2-or-later", 154 | "gfdl-1.3" => "GFDL-1.3", 155 | "gfdl-1.3-only" => "GFDL-1.3-only", 156 | "gfdl-1.3-or-later" => "GFDL-1.3-or-later", 157 | "gl2ps" => "GL2PS", 158 | "gpl-1.0" => "GPL-1.0", 159 | "gpl-1.0+" => "GPL-1.0+", 160 | "gpl-1.0-only" => "GPL-1.0-only", 161 | "gpl-1.0-or-later" => "GPL-1.0-or-later", 162 | "gpl-2.0" => "GPL-2.0", 163 | "gpl-2.0+" => "GPL-2.0+", 164 | "gpl-2.0-only" => "GPL-2.0-only", 165 | "gpl-2.0-or-later" => "GPL-2.0-or-later", 166 | "gpl-2.0-with-gcc-exception" => "GPL-2.0-with-GCC-exception", 167 | "gpl-2.0-with-autoconf-exception" => "GPL-2.0-with-autoconf-exception", 168 | "gpl-2.0-with-bison-exception" => "GPL-2.0-with-bison-exception", 169 | "gpl-2.0-with-classpath-exception" => "GPL-2.0-with-classpath-exception", 170 | "gpl-2.0-with-font-exception" => "GPL-2.0-with-font-exception", 171 | "gpl-3.0" => "GPL-3.0", 172 | "gpl-3.0+" => "GPL-3.0+", 173 | "gpl-3.0-only" => "GPL-3.0-only", 174 | "gpl-3.0-or-later" => "GPL-3.0-or-later", 175 | "gpl-3.0-with-gcc-exception" => "GPL-3.0-with-GCC-exception", 176 | "gpl-3.0-with-autoconf-exception" => "GPL-3.0-with-autoconf-exception", 177 | "giftware" => "Giftware", 178 | "glide" => "Glide", 179 | "glulxe" => "Glulxe", 180 | "hpnd" => "HPND", 181 | "hpnd-sell-variant" => "HPND-sell-variant", 182 | "haskellreport" => "HaskellReport", 183 | "ibm-pibs" => "IBM-pibs", 184 | "icu" => "ICU", 185 | "ijg" => "IJG", 186 | "ipa" => "IPA", 187 | "ipl-1.0" => "IPL-1.0", 188 | "isc" => "ISC", 189 | "imagemagick" => "ImageMagick", 190 | "imlib2" => "Imlib2", 191 | "info-zip" => "Info-ZIP", 192 | "intel" => "Intel", 193 | "intel-acpi" => "Intel-ACPI", 194 | "interbase-1.0" => "Interbase-1.0", 195 | "jpnic" => "JPNIC", 196 | "json" => "JSON", 197 | "jasper-2.0" => "JasPer-2.0", 198 | "lal-1.2" => "LAL-1.2", 199 | "lal-1.3" => "LAL-1.3", 200 | "lgpl-2.0" => "LGPL-2.0", 201 | "lgpl-2.0+" => "LGPL-2.0+", 202 | "lgpl-2.0-only" => "LGPL-2.0-only", 203 | "lgpl-2.0-or-later" => "LGPL-2.0-or-later", 204 | "lgpl-2.1" => "LGPL-2.1", 205 | "lgpl-2.1+" => "LGPL-2.1+", 206 | "lgpl-2.1-only" => "LGPL-2.1-only", 207 | "lgpl-2.1-or-later" => "LGPL-2.1-or-later", 208 | "lgpl-3.0" => "LGPL-3.0", 209 | "lgpl-3.0+" => "LGPL-3.0+", 210 | "lgpl-3.0-only" => "LGPL-3.0-only", 211 | "lgpl-3.0-or-later" => "LGPL-3.0-or-later", 212 | "lgpllr" => "LGPLLR", 213 | "lpl-1.0" => "LPL-1.0", 214 | "lpl-1.02" => "LPL-1.02", 215 | "lppl-1.0" => "LPPL-1.0", 216 | "lppl-1.1" => "LPPL-1.1", 217 | "lppl-1.2" => "LPPL-1.2", 218 | "lppl-1.3a" => "LPPL-1.3a", 219 | "lppl-1.3c" => "LPPL-1.3c", 220 | "latex2e" => "Latex2e", 221 | "leptonica" => "Leptonica", 222 | "liliq-p-1.1" => "LiLiQ-P-1.1", 223 | "liliq-r-1.1" => "LiLiQ-R-1.1", 224 | "liliq-rplus-1.1" => "LiLiQ-Rplus-1.1", 225 | "libpng" => "Libpng", 226 | "linux-openib" => "Linux-OpenIB", 227 | "mit" => "MIT", 228 | "mit-0" => "MIT-0", 229 | "mit-cmu" => "MIT-CMU", 230 | "mit-advertising" => "MIT-advertising", 231 | "mit-enna" => "MIT-enna", 232 | "mit-feh" => "MIT-feh", 233 | "mitnfa" => "MITNFA", 234 | "mpl-1.0" => "MPL-1.0", 235 | "mpl-1.1" => "MPL-1.1", 236 | "mpl-2.0" => "MPL-2.0", 237 | "mpl-2.0-no-copyleft-exception" => "MPL-2.0-no-copyleft-exception", 238 | "ms-pl" => "MS-PL", 239 | "ms-rl" => "MS-RL", 240 | "mtll" => "MTLL", 241 | "makeindex" => "MakeIndex", 242 | "miros" => "MirOS", 243 | "motosoto" => "Motosoto", 244 | "multics" => "Multics", 245 | "mup" => "Mup", 246 | "nasa-1.3" => "NASA-1.3", 247 | "nbpl-1.0" => "NBPL-1.0", 248 | "ncsa" => "NCSA", 249 | "ngpl" => "NGPL", 250 | "nlod-1.0" => "NLOD-1.0", 251 | "nlpl" => "NLPL", 252 | "nosl" => "NOSL", 253 | "npl-1.0" => "NPL-1.0", 254 | "npl-1.1" => "NPL-1.1", 255 | "nposl-3.0" => "NPOSL-3.0", 256 | "nrl" => "NRL", 257 | "ntp" => "NTP", 258 | "naumen" => "Naumen", 259 | "net-snmp" => "Net-SNMP", 260 | "netcdf" => "NetCDF", 261 | "newsletr" => "Newsletr", 262 | "nokia" => "Nokia", 263 | "noweb" => "Noweb", 264 | "nunit" => "Nunit", 265 | "occt-pl" => "OCCT-PL", 266 | "oclc-2.0" => "OCLC-2.0", 267 | "odc-by-1.0" => "ODC-By-1.0", 268 | "odbl-1.0" => "ODbL-1.0", 269 | "ofl-1.0" => "OFL-1.0", 270 | "ofl-1.1" => "OFL-1.1", 271 | "ogl-uk-1.0" => "OGL-UK-1.0", 272 | "ogl-uk-2.0" => "OGL-UK-2.0", 273 | "ogl-uk-3.0" => "OGL-UK-3.0", 274 | "ogtsl" => "OGTSL", 275 | "oldap-1.1" => "OLDAP-1.1", 276 | "oldap-1.2" => "OLDAP-1.2", 277 | "oldap-1.3" => "OLDAP-1.3", 278 | "oldap-1.4" => "OLDAP-1.4", 279 | "oldap-2.0" => "OLDAP-2.0", 280 | "oldap-2.0.1" => "OLDAP-2.0.1", 281 | "oldap-2.1" => "OLDAP-2.1", 282 | "oldap-2.2" => "OLDAP-2.2", 283 | "oldap-2.2.1" => "OLDAP-2.2.1", 284 | "oldap-2.2.2" => "OLDAP-2.2.2", 285 | "oldap-2.3" => "OLDAP-2.3", 286 | "oldap-2.4" => "OLDAP-2.4", 287 | "oldap-2.5" => "OLDAP-2.5", 288 | "oldap-2.6" => "OLDAP-2.6", 289 | "oldap-2.7" => "OLDAP-2.7", 290 | "oldap-2.8" => "OLDAP-2.8", 291 | "oml" => "OML", 292 | "opl-1.0" => "OPL-1.0", 293 | "oset-pl-2.1" => "OSET-PL-2.1", 294 | "osl-1.0" => "OSL-1.0", 295 | "osl-1.1" => "OSL-1.1", 296 | "osl-2.0" => "OSL-2.0", 297 | "osl-2.1" => "OSL-2.1", 298 | "osl-3.0" => "OSL-3.0", 299 | "openssl" => "OpenSSL", 300 | "pddl-1.0" => "PDDL-1.0", 301 | "php-3.0" => "PHP-3.0", 302 | "php-3.01" => "PHP-3.01", 303 | "parity-6.0.0" => "Parity-6.0.0", 304 | "plexus" => "Plexus", 305 | "postgresql" => "PostgreSQL", 306 | "python-2.0" => "Python-2.0", 307 | "qpl-1.0" => "QPL-1.0", 308 | "qhull" => "Qhull", 309 | "rhecos-1.1" => "RHeCos-1.1", 310 | "rpl-1.1" => "RPL-1.1", 311 | "rpl-1.5" => "RPL-1.5", 312 | "rpsl-1.0" => "RPSL-1.0", 313 | "rsa-md" => "RSA-MD", 314 | "rscpl" => "RSCPL", 315 | "rdisc" => "Rdisc", 316 | "ruby" => "Ruby", 317 | "sax-pd" => "SAX-PD", 318 | "scea" => "SCEA", 319 | "sgi-b-1.0" => "SGI-B-1.0", 320 | "sgi-b-1.1" => "SGI-B-1.1", 321 | "sgi-b-2.0" => "SGI-B-2.0", 322 | "shl-0.5" => "SHL-0.5", 323 | "shl-0.51" => "SHL-0.51", 324 | "sissl" => "SISSL", 325 | "sissl-1.2" => "SISSL-1.2", 326 | "smlnj" => "SMLNJ", 327 | "smppl" => "SMPPL", 328 | "snia" => "SNIA", 329 | "spl-1.0" => "SPL-1.0", 330 | "ssh-openssh" => "SSH-OpenSSH", 331 | "ssh-short" => "SSH-short", 332 | "sspl-1.0" => "SSPL-1.0", 333 | "swl" => "SWL", 334 | "saxpath" => "Saxpath", 335 | "sendmail" => "Sendmail", 336 | "sendmail-8.23" => "Sendmail-8.23", 337 | "simpl-2.0" => "SimPL-2.0", 338 | "sleepycat" => "Sleepycat", 339 | "spencer-86" => "Spencer-86", 340 | "spencer-94" => "Spencer-94", 341 | "spencer-99" => "Spencer-99", 342 | "standardml-nj" => "StandardML-NJ", 343 | "sugarcrm-1.1.3" => "SugarCRM-1.1.3", 344 | "tapr-ohl-1.0" => "TAPR-OHL-1.0", 345 | "tcl" => "TCL", 346 | "tcp-wrappers" => "TCP-wrappers", 347 | "tmate" => "TMate", 348 | "torque-1.1" => "TORQUE-1.1", 349 | "tosl" => "TOSL", 350 | "tu-berlin-1.0" => "TU-Berlin-1.0", 351 | "tu-berlin-2.0" => "TU-Berlin-2.0", 352 | "ucl-1.0" => "UCL-1.0", 353 | "upl-1.0" => "UPL-1.0", 354 | "unicode-dfs-2015" => "Unicode-DFS-2015", 355 | "unicode-dfs-2016" => "Unicode-DFS-2016", 356 | "unicode-tou" => "Unicode-TOU", 357 | "unlicense" => "Unlicense", 358 | "vostrom" => "VOSTROM", 359 | "vsl-1.0" => "VSL-1.0", 360 | "vim" => "Vim", 361 | "w3c" => "W3C", 362 | "w3c-19980720" => "W3C-19980720", 363 | "w3c-20150513" => "W3C-20150513", 364 | "wtfpl" => "WTFPL", 365 | "watcom-1.0" => "Watcom-1.0", 366 | "wsuipa" => "Wsuipa", 367 | "x11" => "X11", 368 | "xfree86-1.1" => "XFree86-1.1", 369 | "xskat" => "XSkat", 370 | "xerox" => "Xerox", 371 | "xnet" => "Xnet", 372 | "ypl-1.0" => "YPL-1.0", 373 | "ypl-1.1" => "YPL-1.1", 374 | "zpl-1.1" => "ZPL-1.1", 375 | "zpl-2.0" => "ZPL-2.0", 376 | "zpl-2.1" => "ZPL-2.1", 377 | "zed" => "Zed", 378 | "zend-2.0" => "Zend-2.0", 379 | "zimbra-1.3" => "Zimbra-1.3", 380 | "zimbra-1.4" => "Zimbra-1.4", 381 | "zlib" => "Zlib", 382 | "blessing" => "blessing", 383 | "bzip2-1.0.5" => "bzip2-1.0.5", 384 | "bzip2-1.0.6" => "bzip2-1.0.6", 385 | "copyleft-next-0.3.0" => "copyleft-next-0.3.0", 386 | "copyleft-next-0.3.1" => "copyleft-next-0.3.1", 387 | "curl" => "curl", 388 | "diffmark" => "diffmark", 389 | "dvipdfm" => "dvipdfm", 390 | "ecos-2.0" => "eCos-2.0", 391 | "egenix" => "eGenix", 392 | "etalab-2.0" => "etalab-2.0", 393 | "gsoap-1.3b" => "gSOAP-1.3b", 394 | "gnuplot" => "gnuplot", 395 | "imatix" => "iMatix", 396 | "libpng-2.0" => "libpng-2.0", 397 | "libtiff" => "libtiff", 398 | "mpich2" => "mpich2", 399 | "psfrag" => "psfrag", 400 | "psutils" => "psutils", 401 | "wxwindows" => "wxWindows", 402 | "xinetd" => "xinetd", 403 | "xpp" => "xpp", 404 | "zlib-acknowledgement" => "zlib-acknowledgement" 405 | } 406 | 407 | def spdx_id(id) do 408 | @spdx_id[normalize(id)] 409 | end 410 | 411 | defp normalize(s) do 412 | s 413 | |> String.downcase() 414 | |> String.replace(~r/[ ,]+/, "-") 415 | |> fixup() 416 | end 417 | 418 | defp fixup("apache-2"), do: "apache-2.0" 419 | defp fixup("apache-license-2.0"), do: "apache-2.0" 420 | defp fixup("bsd-3"), do: "bsd-3-clause" 421 | defp fixup("mit-license"), do: "mit" 422 | defp fixup("mozilla-public-license-version-2.0"), do: "mpl-2.0" 423 | defp fixup(id), do: id 424 | end 425 | -------------------------------------------------------------------------------- /lib/sbom/purl.ex: -------------------------------------------------------------------------------- 1 | defmodule SBoM.Purl do 2 | @moduledoc false 3 | 4 | # https://github.com/package-url/purl-spec 5 | 6 | def hex(name, version, repo \\ "hexpm") do 7 | do_hex(String.downcase(name), version, String.downcase(repo)) 8 | end 9 | 10 | defp do_hex(name, version, "hexpm") do 11 | purl(["hex", name], version) 12 | end 13 | 14 | defp do_hex(name, version, "hexpm:" <> organization) do 15 | purl(["hex", organization, name], version) 16 | end 17 | 18 | defp do_hex(name, version, repo) do 19 | case Hex.Repo.fetch_repo(repo) do 20 | {:ok, %{url: url}} -> 21 | purl(["hex", name], version, repository_url: url) 22 | 23 | :error -> 24 | raise "Undefined Hex repo: #{repo}" 25 | end 26 | end 27 | 28 | def git(_name, "git@github.com:" <> github, commit_or_tag) do 29 | github |> String.replace_suffix(".git", "") |> github(commit_or_tag) 30 | end 31 | 32 | def git(_name, "https://github.com/" <> github, commit_or_tag) do 33 | github |> String.replace_suffix(".git", "") |> github(commit_or_tag) 34 | end 35 | 36 | def git(_name, "git@bitbucket.org:" <> bitbucket, commit_or_tag) do 37 | bitbucket |> String.replace_suffix(".git", "") |> bitbucket(commit_or_tag) 38 | end 39 | 40 | def git(_name, "https://bitbucket.org/" <> bitbucket, commit_or_tag) do 41 | bitbucket |> String.replace_suffix(".git", "") |> bitbucket(commit_or_tag) 42 | end 43 | 44 | # Git dependence other than GitHub and BitBucket are not currently supported 45 | def git(_name, _git, _commit_or_tag), do: nil 46 | 47 | def github(github, commit_or_tag) do 48 | [organization, repository | _] = String.split(github, "/") 49 | name = repository |> String.downcase() 50 | purl(["github", String.downcase(organization), name], commit_or_tag) 51 | end 52 | 53 | def bitbucket(bitbucket, commit_or_tag) do 54 | [organization, repository | _] = String.split(bitbucket, "/") 55 | name = repository |> String.downcase() 56 | purl(["bitbucket", String.downcase(organization), name], commit_or_tag) 57 | end 58 | 59 | defp purl(type_namespace_name, version, qualifiers \\ []) do 60 | path = 61 | type_namespace_name 62 | |> Enum.map(&URI.encode/1) 63 | |> Enum.join("/") 64 | 65 | %URI{ 66 | scheme: "pkg", 67 | path: "#{path}@#{URI.encode(version)}", 68 | query: 69 | case URI.encode_query(qualifiers) do 70 | "" -> nil 71 | query -> query 72 | end 73 | } 74 | |> to_string() 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SBoM.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.7.0" 5 | 6 | def project do 7 | [ 8 | app: :sbom, 9 | version: @version, 10 | elixir: "~> 1.7", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | name: "SBoM", 14 | description: description(), 15 | package: package(), 16 | docs: docs(), 17 | source_url: "https://github.com/voltone/sbom" 18 | ] 19 | end 20 | 21 | # Run "mix help compile.app" to learn about applications. 22 | def application do 23 | [ 24 | extra_applications: [:hex, :xmerl] 25 | ] 26 | end 27 | 28 | # Run "mix help deps" to learn about dependencies. 29 | defp deps do 30 | [ 31 | {:ex_doc, "~> 0.21", only: :dev} 32 | ] 33 | end 34 | 35 | defp description do 36 | "Mix task to generate a Software Bill-of-Materials (SBoM) in CycloneDX format" 37 | end 38 | 39 | defp package do 40 | [ 41 | maintainers: ["Bram Verburg"], 42 | licenses: ["BSD-3-Clause"], 43 | links: %{"GitHub" => "https://github.com/voltone/sbom"} 44 | ] 45 | end 46 | 47 | defp docs do 48 | [ 49 | main: "readme", 50 | extras: ["README.md"], 51 | source_ref: "v#{@version}" 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/fixtures/sample1/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sample1.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sample1, 7 | version: "0.1.0", 8 | elixir: "~> 1.8", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | {:hackney, "~> 1.15"}, 25 | {:sweet_xml, "~> 0.6.6"}, 26 | {:jason, "~> 1.1", optional: true}, 27 | {:ex_doc, "~> 0.21.2", only: :dev}, 28 | {:meck, "~> 0.8.13", only: :test} 29 | ] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/fixtures/sample1/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~> 3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, 4 | "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", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, 5 | "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", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 6 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 7 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, 8 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 10 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, 11 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 12 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, 14 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 15 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 16 | "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, 17 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/with_path_dep/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WithPathDep.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :with_path_dep, 7 | version: "0.1.0", 8 | elixir: "~> 1.8", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | {:sample1, path: "../sample1"}, 25 | {:jason, "~> 1.1"} 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/fixtures/with_path_dep/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~> 3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "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", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 4 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 5 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, 6 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 7 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 8 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 9 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 10 | "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, 11 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 12 | } 13 | -------------------------------------------------------------------------------- /test/mix/tasks/sbom.cyclonedx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Sbom.CyclonedxTest do 2 | use ExUnit.Case 3 | 4 | setup_all do 5 | Mix.shell(Mix.Shell.Process) 6 | :ok 7 | end 8 | 9 | setup do 10 | Mix.Shell.Process.flush() 11 | :ok 12 | end 13 | 14 | test "mix task" do 15 | Mix.Project.in_project(__MODULE__, "test/fixtures/sample1", fn _mod -> 16 | Mix.Task.rerun("deps.clean", ["--all"]) 17 | 18 | assert_raise Mix.Error, "Can't continue due to errors on dependencies", fn -> 19 | Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f"]) 20 | end 21 | 22 | Mix.Task.rerun("deps.get") 23 | Mix.Shell.Process.flush() 24 | 25 | Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f"]) 26 | assert_received {:mix_shell, :info, ["* creating bom.xml"]} 27 | end) 28 | end 29 | 30 | test "schema validation" do 31 | Mix.Project.in_project(__MODULE__, "test/fixtures/sample1", fn _mod -> 32 | Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f", "-s", "1.1"]) 33 | assert_received {:mix_shell, :info, ["* creating bom.xml"]} 34 | 35 | assert_raise Mix.Error, "Give correct cyclonedx schema version to continue.", fn -> 36 | Mix.Task.rerun("sbom.cyclonedx", ["-d", "-f", "-s", "invalid"]) 37 | end 38 | end) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/sbom/cyclonedx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SBoM.CycloneDXTest do 2 | use ExUnit.Case 3 | import SBoM.CycloneDX 4 | 5 | doctest SBoM.CycloneDX 6 | 7 | @opts [ 8 | schema: "1.6", 9 | format: "xml" 10 | ] 11 | 12 | @sample_component %{ 13 | type: "library", 14 | name: "name", 15 | version: "0.0.1", 16 | purl: "pkg:hex/name@0.0.1", 17 | licenses: ["Apache-2.0"] 18 | } 19 | 20 | describe "bom" do 21 | test "serial number UUID generation" do 22 | assert [@sample_component] |> bom(@opts) |> to_string() =~ 23 | ~r(serialNumber="urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") 24 | end 25 | 26 | test "component without license" do 27 | xml = 28 | [ 29 | Map.put(@sample_component, :licenses, []) 30 | ] 31 | |> bom(@opts) 32 | |> to_string() 33 | 34 | assert xml =~ ~s() 35 | assert xml =~ ~s(name) 36 | assert xml =~ ~s(0.0.1) 37 | assert xml =~ ~s(pkg:hex/name@0.0.1) 38 | refute xml =~ ~s() 39 | end 40 | 41 | test "component with SPDX license" do 42 | xml = 43 | [ 44 | @sample_component 45 | ] 46 | |> bom(@opts) 47 | |> to_string() 48 | 49 | assert xml =~ ~s(Apache-2.0) 50 | end 51 | 52 | test "component with other license" do 53 | xml = 54 | [ 55 | Map.put(@sample_component, :licenses, ["Some other license"]) 56 | ] 57 | |> bom(@opts) 58 | |> to_string() 59 | 60 | assert xml =~ ~s(Some other license) 61 | end 62 | 63 | test "component with hash" do 64 | xml = 65 | [ 66 | Map.put(@sample_component, :hashes, %{ 67 | "SHA-256" => "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe" 68 | }) 69 | ] 70 | |> bom(@opts) 71 | |> to_string() 72 | 73 | assert xml =~ 74 | ~s(fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/sbom/license_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SBoM.LicenseTest do 2 | use ExUnit.Case 3 | import SBoM.License 4 | 5 | doctest SBoM.License 6 | 7 | test :spdx_id do 8 | assert "0BSD" = spdx_id("0BSD") 9 | assert "MIT" = spdx_id("mit") 10 | assert "BSD-3-Clause" = spdx_id("BSD 3-clause") 11 | assert "Apache-2.0" = spdx_id("APACHE-2.0") 12 | assert is_nil(spdx_id("Some other license")) 13 | # Fixups: 14 | assert "Apache-2.0" = spdx_id("Apache 2") 15 | assert "Apache-2.0" = spdx_id("Apache license 2.0") 16 | assert "BSD-3-Clause" = spdx_id("BSD-3") 17 | assert "MIT" = spdx_id("MIT license") 18 | assert "MPL-2.0" = spdx_id("Mozilla Public License version 2.0") 19 | assert "MPL-2.0" = spdx_id("Mozilla Public License, version 2.0") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/sbom/purl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SBoM.PurlTest do 2 | use ExUnit.Case 3 | import SBoM.Purl 4 | 5 | doctest SBoM.Purl 6 | 7 | setup_all do 8 | repos = 9 | Hex.State.fetch!(:repos) 10 | |> Map.put("myrepo", %{url: "https://myrepo.example.com"}) 11 | 12 | Hex.State.put(:repos, repos) 13 | end 14 | 15 | test :hex do 16 | assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2") 17 | assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2", "hexpm") 18 | assert "pkg:hex/acme/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:acme") 19 | 20 | assert "pkg:hex/jason@1.1.2" = hex("Jason", "1.1.2") 21 | assert "pkg:hex/jason@1.1.2" = hex("jason", "1.1.2", "HEXPM") 22 | assert "pkg:hex/acme/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:Acme") 23 | 24 | assert "pkg:hex/jason@1.1.2%25" = hex("jason", "1.1.2%") 25 | # Not a valid organization name for Hex, but that's not what we're 26 | # testing here 27 | assert "pkg:hex/acme%25/foo@2.3.4" = hex("foo", "2.3.4", "hexpm:acme%") 28 | 29 | assert "pkg:hex/bar@1.2.3?repository_url=https%3A%2F%2Fmyrepo.example.com" = 30 | hex("bar", "1.2.3", "myrepo") 31 | end 32 | 33 | test :git do 34 | assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = 35 | git("package-url", "https://github.com/package-url/purl-spec.git", "244fd47e07d1004") 36 | 37 | assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = 38 | git("package-url", "git@github.com:package-url/purl-spec.git", "244fd47e07d1004") 39 | 40 | assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = 41 | git( 42 | "pygments-main", 43 | "https://bitbucket.org/birkenfeld/pygments-main.git", 44 | "244fd47e07d1014f0aed9c" 45 | ) 46 | 47 | assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = 48 | git( 49 | "pygments-main", 50 | "git@bitbucket.org:birkenfeld/pygments-main.git", 51 | "244fd47e07d1014f0aed9c" 52 | ) 53 | 54 | assert is_nil(git("ignored", "git@internal.host:some/project", "deadbeef")) 55 | end 56 | 57 | test :github do 58 | assert "pkg:github/package-url/purl-spec@244fd47e07d1004" = 59 | github("package-url/purl-spec", "244fd47e07d1004") 60 | end 61 | 62 | test :bitbucket do 63 | assert "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c" = 64 | bitbucket("birkenfeld/pygments-main", "244fd47e07d1014f0aed9c") 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/sbom_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SBoMTest do 2 | use ExUnit.Case 3 | doctest SBoM 4 | 5 | setup_all do 6 | Mix.shell(Mix.Shell.Process) 7 | :ok 8 | end 9 | 10 | setup do 11 | Mix.Shell.Process.flush() 12 | :ok 13 | end 14 | 15 | describe "components_for_project" do 16 | test "basic project" do 17 | Mix.Project.in_project(:sample1, "test/fixtures/sample1", fn _mod -> 18 | Mix.Task.rerun("deps.clean", ["--all"]) 19 | assert {:error, :unresolved_dependency} = SBoM.components_for_project("application") 20 | 21 | Mix.Task.rerun("deps.get") 22 | assert {:ok, list} = SBoM.components_for_project("application") 23 | assert length(list) == 10 24 | assert Enum.find(list, &match?(%{name: "hackney"}, &1)) 25 | assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) 26 | refute Enum.find(list, &match?(%{name: "ex_doc"}, &1)) 27 | refute Enum.find(list, &match?(%{name: "meck"}, &1)) 28 | refute Enum.find(list, &match?(%{name: "jason"}, &1)) 29 | 30 | assert {:ok, list} = SBoM.components_for_project("application", nil) 31 | assert length(list) == 16 32 | assert Enum.find(list, &match?(%{name: "hackney"}, &1)) 33 | assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) 34 | assert Enum.find(list, &match?(%{name: "ex_doc"}, &1)) 35 | assert Enum.find(list, &match?(%{name: "meck"}, &1)) 36 | refute Enum.find(list, &match?(%{name: "jason"}, &1)) 37 | 38 | assert %{cpe: "cpe:2.3:a:kbrw:sweet_xml:0.6.6:*:*:*:*:*:*:*"} = 39 | Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) 40 | 41 | assert %{ 42 | licenses: ["Apache 2.0"], 43 | description: "ExDoc is a documentation generation tool for Elixir" 44 | } = Enum.find(list, &match?(%{name: "ex_doc"}, &1)) 45 | end) 46 | end 47 | 48 | test "project with path dependency" do 49 | Mix.Project.in_project(:with_path_dep, "test/fixtures/with_path_dep", fn _mod -> 50 | Mix.Task.rerun("deps.clean", ["--all"]) 51 | assert {:error, :unresolved_dependency} = SBoM.components_for_project("application") 52 | 53 | Mix.Task.rerun("deps.get") 54 | assert {:ok, list} = SBoM.components_for_project("application") 55 | assert length(list) == 11 56 | assert Enum.find(list, &match?(%{name: "hackney"}, &1)) 57 | assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) 58 | refute Enum.find(list, &match?(%{name: "ex_doc"}, &1)) 59 | refute Enum.find(list, &match?(%{name: "meck"}, &1)) 60 | assert Enum.find(list, &match?(%{name: "jason"}, &1)) 61 | 62 | assert {:ok, list} = SBoM.components_for_project("application", nil) 63 | assert length(list) == 11 64 | assert Enum.find(list, &match?(%{name: "hackney"}, &1)) 65 | assert Enum.find(list, &match?(%{name: "sweet_xml"}, &1)) 66 | refute Enum.find(list, &match?(%{name: "ex_doc"}, &1)) 67 | refute Enum.find(list, &match?(%{name: "meck"}, &1)) 68 | assert Enum.find(list, &match?(%{name: "jason"}, &1)) 69 | end) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------