Skip to main content

lib/npm/security/exotic_deps.ex

defmodule NPM.Security.ExoticDeps do
  @moduledoc """
  Detects and blocks exotic dependency specs in published package metadata.

  Registry packages can declare dependencies that resolve from outside the
  configured registry, such as Git repositories, direct tarball URLs, local
  files, or GitHub shorthand specs. Those sources bypass the normal registry
  integrity and metadata flow and have been used by supply-chain malware to
  trigger hidden build steps through transitive `optionalDependencies`.

  `npm_ex` blocks these transitive specs by default. Direct project dependencies
  are still controlled by the root manifest; this module protects against a
  package from the registry unexpectedly introducing an external source deeper
  in the dependency graph.
  """

  defmodule Error do
    @moduledoc """
    Raised when a dependency points at an exotic source blocked by policy.
    """

    defexception [:package, :version, :field, :dependency, :spec, :direct?]

    @impl true
    def message(%__MODULE__{direct?: true} = error) do
      "#{error.dependency}: #{error.spec} is an exotic direct dependency. " <>
        "Add the exact spec to config :duskmoon_npm, exotic_deps: [...] or NPM_EX_EXOTIC_DEPS to allow it."
    end

    def message(%__MODULE__{} = error) do
      "#{error.package}@#{error.version} declares exotic #{error.field} entry " <>
        "#{error.dependency}: #{error.spec}. Transitive git, file, and URL dependencies are blocked by default."
    end
  end

  @fields [
    {:dependencies, "dependencies"},
    {:optional_dependencies, "optionalDependencies"}
  ]
  @exotic_prefixes ~w(file: git+ git:// github: ssh:// http:// https://)
  @github_shorthand_regex ~r/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$/

  @doc "Validate a direct project dependency against the exotic dependency allowlist."
  @spec validate_direct!(String.t(), term()) :: :ok
  def validate_direct!(dependency, spec) do
    if exotic?(spec) and spec not in NPM.Config.exotic_deps() do
      raise Error, dependency: dependency, spec: spec, direct?: true
    end

    :ok
  end

  @spec validate!(String.t(), String.t(), map()) :: :ok
  def validate!(package, version, info) do
    if NPM.Config.block_exotic_subdeps?() do
      Enum.each(@fields, &validate_field!(&1, package, version, info))
    end

    :ok
  end

  defp validate_field!({key, field}, package, version, info) do
    info
    |> Map.get(key, %{})
    |> Enum.each(fn {dependency, spec} ->
      if exotic?(spec) do
        raise Error,
          package: package,
          version: version,
          field: field,
          dependency: dependency,
          spec: spec
      end
    end)
  end

  @spec exotic?(term()) :: boolean()
  def exotic?(spec) when is_binary(spec) do
    spec = String.trim(spec)

    has_exotic_prefix?(spec) or github_shorthand?(spec)
  end

  def exotic?(_), do: false

  defp has_exotic_prefix?(spec) do
    Enum.any?(@exotic_prefixes, &String.starts_with?(spec, &1))
  end

  defp github_shorthand?(spec) do
    Regex.match?(@github_shorthand_regex, spec)
  end
end