Skip to main content

lib/npm/sbom.ex

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