defmodule NPM.SBOM do @moduledoc """ Software Bill of Materials (SBOM) generation. Generates CycloneDX-compatible SBOM data from the lockfile, listing all dependencies with versions, licenses, and integrity hashes. """ @doc """ Generates an SBOM from the lockfile and node_modules. """ @spec generate(map(), String.t()) :: map() def generate(lockfile, node_modules_dir \\ "node_modules") do components = lockfile |> Enum.map(fn {name, entry} -> build_component(name, entry, node_modules_dir) end) |> Enum.sort_by(& &1.name) %{ bom_format: "CycloneDX", spec_version: "1.4", version: 1, components: components, metadata: %{ timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), tools: [%{vendor: "npm_ex", name: "npm_ex"}] } } end @doc """ Generates a minimal SBOM from lockfile only (no filesystem access). """ @spec from_lockfile(map()) :: map() def from_lockfile(lockfile) do components = lockfile |> Enum.map(fn {name, entry} -> %{ type: "library", name: name, version: entry_version(entry), purl: purl(name, entry_version(entry)), hashes: integrity_hashes(entry) } end) |> Enum.sort_by(& &1.name) %{ bom_format: "CycloneDX", spec_version: "1.4", version: 1, components: components } end @doc """ Returns the count of components in the SBOM. """ @spec component_count(map()) :: non_neg_integer() def component_count(%{components: components}), do: length(components) def component_count(_), do: 0 @doc """ Generates a Package URL (purl) for an npm package. """ @spec purl(String.t(), String.t()) :: String.t() def purl(name, version) do "pkg:npm/#{name}@#{version}" end @doc """ Filters SBOM components by a predicate. """ @spec filter(map(), (map() -> boolean())) :: map() def filter(%{components: components} = sbom, fun) do %{sbom | components: Enum.filter(components, fun)} end defp build_component(name, entry, nm_dir) do version = entry_version(entry) license = read_license(nm_dir, name) component = %{ type: "library", name: name, version: version, purl: purl(name, version), hashes: integrity_hashes(entry) } if license, do: Map.put(component, :license, license), else: component end defp entry_version(%{version: v}), do: v defp entry_version(%{"version" => v}), do: v defp entry_version(_), do: "0.0.0" defp integrity_hashes(%{integrity: "sha512-" <> hash}) do [%{alg: "SHA-512", content: hash}] end defp integrity_hashes(%{integrity: "sha256-" <> hash}) do [%{alg: "SHA-256", content: hash}] end defp integrity_hashes(_), do: [] defp read_license(nm_dir, name) do pkg_path = Path.join([nm_dir, name, "package.json"]) case File.read(pkg_path) do {:ok, content} -> data = NPM.JSON.decode!(content) data["license"] _ -> nil end rescue _ -> nil end end