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