|
| 1 | +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +defmodule Mix.Tasks.Trinity.Sbom do |
| 4 | + @shortdoc "Generates the CycloneDX software bill of materials, and says what it is blind to" |
| 5 | + @moduledoc """ |
| 6 | + Slice 002. Runs `mix sbom.cyclonedx` over `mix.lock` and writes `sbom.cdx.json`, then makes two |
| 7 | + corrections to what it generated. Neither is decoration: without the first the bill is read as |
| 8 | + saying more than it knows, and without the second it is not a valid CycloneDX document at all. |
| 9 | +
|
| 10 | + **What the bill covers, and what it does not.** It is generated from `mix.lock`, so it is exact |
| 11 | + for Hex dependencies and blind to the Rust crates the desktop shell links. A reader who takes it |
| 12 | + for the whole picture would conclude the product has no Rust in it, which is false. Rather than |
| 13 | + leave that to a paragraph in a document nobody reads beside the file, the coverage is written |
| 14 | + into the bill itself as a `metadata.properties` entry, where a tool or a person reading the |
| 15 | + artifact alone will find it. |
| 16 | +
|
| 17 | + **Licence identifiers that SPDX does not define.** CycloneDX validates `license.id` against the |
| 18 | + SPDX enumeration and rejects a bill carrying anything else, while a Hex package's `licenses` |
| 19 | + metadata is free text that its author typed. `sbom` emits a lone licence string as an `id` |
| 20 | + without checking it (`deps/sbom/lib/sbom/cyclonedx.ex`, `license: {:id, license}`), so one |
| 21 | + dependency writing `BSD 2-Clause` for `BSD-2-Clause` makes the whole document fail validation: |
| 22 | + measured, not supposed, on the first bill this project generated. Any identifier this project |
| 23 | + cannot find in the vendored SPDX list is therefore moved to `license.name`, which is where |
| 24 | + CycloneDX puts a licence it cannot resolve to an identifier. Nothing is dropped and nothing is |
| 25 | + guessed: `BSD 2-Clause` stays `BSD 2-Clause`, recorded as a name rather than asserted as an |
| 26 | + identifier it is not. |
| 27 | +
|
| 28 | + The gate runs this, so a bill that cannot be generated fails the build rather than being missing |
| 29 | + from a release nobody checked. |
| 30 | + """ |
| 31 | + use Boundary, classify_to: Trinity |
| 32 | + use Mix.Task |
| 33 | + |
| 34 | + @output "sbom.cdx.json" |
| 35 | + @spdx_ids_path "priv/spdx/license-ids.txt" |
| 36 | + @coverage_property "trinity:coverage" |
| 37 | + @coverage_value "Hex dependencies resolved from mix.lock. This bill does NOT cover the Rust " <> |
| 38 | + "crates linked by the desktop shell (src-tauri/Cargo.lock); that half is " <> |
| 39 | + "slice 121. Absence of a component here is not evidence of its absence " <> |
| 40 | + "from the product." |
| 41 | + |
| 42 | + @impl Mix.Task |
| 43 | + def run(argv) do |
| 44 | + output = Enum.at(argv, 0, @output) |
| 45 | + Mix.Task.run("sbom.cyclonedx", ["--force", "--pretty", "--output", output]) |
| 46 | + annotate!(output) |
| 47 | + Mix.shell().info("trinity.sbom: #{output} written and annotated with its coverage") |
| 48 | + end |
| 49 | + |
| 50 | + @doc """ |
| 51 | + The SPDX licence identifiers CycloneDX accepts, as a set. |
| 52 | +
|
| 53 | + Read from the vendored list rather than from a dependency: see the file's own header for where |
| 54 | + it came from and for which way it fails when it goes stale. |
| 55 | + """ |
| 56 | + @spec spdx_ids() :: MapSet.t(String.t()) |
| 57 | + def spdx_ids do |
| 58 | + Application.app_dir(:trinity, @spdx_ids_path) |
| 59 | + |> then(fn path -> if File.exists?(path), do: path, else: @spdx_ids_path end) |
| 60 | + |> File.read!() |
| 61 | + |> String.split("\n", trim: true) |
| 62 | + |> Enum.reject(&String.starts_with?(&1, "#")) |
| 63 | + |> MapSet.new() |
| 64 | + end |
| 65 | + |
| 66 | + @doc "The property name the bill carries its coverage statement under." |
| 67 | + @spec coverage_property() :: String.t() |
| 68 | + def coverage_property, do: @coverage_property |
| 69 | + |
| 70 | + @doc "The coverage statement written into the bill." |
| 71 | + @spec coverage_value() :: String.t() |
| 72 | + def coverage_value, do: @coverage_value |
| 73 | + |
| 74 | + # Read, add the property, write back. A generation that produced something unreadable fails |
| 75 | + # here rather than shipping: the point of the bill is that it can be read. |
| 76 | + defp annotate!(output) do |
| 77 | + bom = |
| 78 | + case output |> File.read!() |> Jason.decode() do |
| 79 | + {:ok, %{"bomFormat" => "CycloneDX"} = bom} -> |
| 80 | + bom |
| 81 | + |
| 82 | + {:ok, other} -> |
| 83 | + Mix.raise("#{output} is not a CycloneDX document: #{inspect(Map.keys(other))}") |
| 84 | + |
| 85 | + {:error, reason} -> |
| 86 | + Mix.raise("#{output} is not readable as JSON: #{inspect(reason)}") |
| 87 | + end |
| 88 | + |
| 89 | + metadata = Map.get(bom, "metadata", %{}) |
| 90 | + |
| 91 | + properties = |
| 92 | + metadata |
| 93 | + |> Map.get("properties", []) |
| 94 | + |> Enum.reject(&(Map.get(&1, "name") == @coverage_property)) |
| 95 | + |> Kernel.++([%{"name" => @coverage_property, "value" => @coverage_value}]) |
| 96 | + |
| 97 | + annotated = |
| 98 | + bom |
| 99 | + |> Map.put("metadata", Map.put(metadata, "properties", properties)) |
| 100 | + |> Map.replace_lazy("components", &normalise_licenses(&1, spdx_ids())) |
| 101 | + |
| 102 | + File.write!(output, Jason.encode_to_iodata!(annotated, pretty: true)) |
| 103 | + end |
| 104 | + |
| 105 | + # An identifier SPDX does not define is not an identifier. It keeps its text and changes field. |
| 106 | + defp normalise_licenses(components, ids) when is_list(components) do |
| 107 | + Enum.map(components, fn component -> |
| 108 | + Map.replace_lazy(component, "licenses", fn choices -> |
| 109 | + Enum.map(choices, &normalise_choice(&1, ids)) |
| 110 | + end) |
| 111 | + end) |
| 112 | + end |
| 113 | + |
| 114 | + defp normalise_licenses(components, _ids), do: components |
| 115 | + |
| 116 | + defp normalise_choice(%{"license" => %{"id" => id} = license} = choice, ids) do |
| 117 | + if MapSet.member?(ids, id) do |
| 118 | + choice |
| 119 | + else |
| 120 | + %{choice | "license" => license |> Map.delete("id") |> Map.put("name", id)} |
| 121 | + end |
| 122 | + end |
| 123 | + |
| 124 | + defp normalise_choice(choice, _ids), do: choice |
| 125 | +end |
0 commit comments