Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## v2.5.1-dev

### Enhancements

* Add `ignore_advisories` and `ignore_retirements` configs to acknowledge security advisories and package retirements. Configure them in the `mix.exs` `:hex` block (`ignore_advisories: ["CVE-2026-32686"]`, `ignore_retirements: [:decimal, phoenix: "1.0.0"]`) or with the `HEX_IGNORE_ADVISORIES` and `HEX_IGNORE_RETIREMENTS` environment variables. `mix hex.audit` lists ignored findings in separate sections without failing and warns about entries that no longer match anything, and `mix deps.get`/`mix deps.update` stop warning about ignored findings

### Bug fixes

* Remove regexes from compiled code. Compiled regex patterns are not portable across OTP versions, so the precompiled archive could crash with `:re.import/1 is undefined` when built and run on different OTP versions
Expand Down
106 changes: 106 additions & 0 deletions lib/hex/ignores.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
defmodule Hex.Ignores do
@moduledoc false

# Parsing and matching for the ignore_advisories and ignore_retirements
# configs. Parsed advisory entries are advisory ID strings. Parsed
# retirement entries are {name, version} tuples where a nil version
# matches every version of the package.

def parse_advisories(nil), do: {:ok, []}
def parse_advisories(""), do: {:ok, []}

def parse_advisories(value) when is_binary(value) do
{:ok, split_env_list(value)}
end

def parse_advisories(value) when is_list(value) do
if Enum.all?(value, &(is_binary(&1) and &1 != "")) do
{:ok, value}
else
:error
end
end

def parse_advisories(_), do: :error

def parse_retirements(nil), do: {:ok, []}
def parse_retirements(""), do: {:ok, []}

def parse_retirements(value) when is_binary(value) do
value
|> split_env_list()
|> Enum.map(&parse_retirement_string/1)
|> collect_entries()
end

def parse_retirements(value) when is_list(value) do
value
|> Enum.map(&parse_retirement_term/1)
|> collect_entries()
end

def parse_retirements(_), do: :error

def advisory_ignored?(advisory, ids) do
Enum.any?(ids, &advisory_matches?(advisory, &1))
end

def advisory_matches?(%{id: advisory_id} = advisory, id) do
id = String.downcase(id)

Enum.any?([advisory_id | Map.get(advisory, :aliases, [])], fn candidate ->
String.downcase(candidate) == id
end)
end

def retirement_ignored?(package, version, entries) do
Enum.any?(entries, &retirement_matches?(package, version, &1))
end

def retirement_matches?(package, _version, {name, nil}), do: package == name

def retirement_matches?(package, version, {name, pinned}),
do: package == name and version == pinned

defp split_env_list(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end

defp parse_retirement_string(entry) do
case String.split(entry, "@", parts: 2) do
[name] -> {:ok, {name, nil}}
[name, version] -> entry(name, version)
end
end

defp parse_retirement_term(name) when is_atom(name) and name not in [nil, true, false] do
{:ok, {Atom.to_string(name), nil}}
end

defp parse_retirement_term({name, version})
when is_atom(name) and name not in [nil, true, false] do
entry(Atom.to_string(name), version)
end

defp parse_retirement_term(_), do: :error

defp entry(name, version) when name != "" and is_binary(version) do
case Version.parse(version) do
{:ok, _} -> {:ok, {name, version}}
:error -> :error
end
end

defp entry(_name, _version), do: :error

defp collect_entries(results) do
if Enum.all?(results, &match?({:ok, _}, &1)) do
{:ok, Enum.map(results, fn {:ok, entry} -> entry end)}
else
:error
end
end
end
22 changes: 20 additions & 2 deletions lib/hex/remote_converger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -552,11 +552,29 @@ defmodule Hex.RemoteConverger do
end

defp annotate_dependency_changes(dep_changes) do
ignore_advisories = Hex.State.fetch!(:ignore_advisories)
ignore_retirements = Hex.State.fetch!(:ignore_retirements)

Enum.map(dep_changes, fn {mod, deps} ->
annotated =
Enum.map(deps, fn {name, repo, _prev, version, _warning} = dep ->
retired = Registry.retired(repo, name, version)
advisories = Registry.advisories(repo, name, version) || []
retired =
case Registry.retired(repo, name, version) do
%{} = retired ->
if Hex.Ignores.retirement_ignored?(name, version, ignore_retirements) do
nil
else
retired
end

nil ->
nil
end

advisories =
(Registry.advisories(repo, name, version) || [])
|> Enum.reject(&Hex.Ignores.advisory_ignored?(&1, ignore_advisories))

{dep, retired, advisories}
end)

Expand Down
14 changes: 14 additions & 0 deletions lib/hex/state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,20 @@ defmodule Hex.State do
default: nil,
fun: {Hex.Policy, :parse_config},
on_invalid: :keep
},
ignore_advisories: %{
env: ["HEX_IGNORE_ADVISORIES"],
config: [:ignore_advisories],
default: [],
skip_env_if_empty: true,
fun: {Hex.Ignores, :parse_advisories}
},
ignore_retirements: %{
env: ["HEX_IGNORE_RETIREMENTS"],
config: [:ignore_retirements],
default: [],
skip_env_if_empty: true,
fun: {Hex.Ignores, :parse_retirements}
}
}

Expand Down
135 changes: 115 additions & 20 deletions lib/mix/tasks/hex.audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ defmodule Mix.Tasks.Hex.Audit do
The task will display advisory details and exit with a non-zero code
if any packages with advisories are found.

## Ignoring advisories and retirements

Advisories that do not affect your project and retirements you cannot act
on yet can be acknowledged in the `:hex` section of your `mix.exs` project
configuration:

def project() do
[
# ...
hex: [
ignore_advisories: ["CVE-2026-32686"],
ignore_retirements: [:decimal, phoenix: "1.0.0"]
]
]
end

`ignore_advisories` lists advisory IDs; an advisory is ignored when the
given ID matches its primary ID or any of its aliases, so a GHSA advisory
can also be ignored by its CVE ID. `ignore_retirements` lists packages by
name, optionally pinned to a single version.

Ignored findings are listed in separate "Ignored" sections and do not cause
a non-zero exit code. Entries that do not match anything in the lock file
produce a warning so they can be cleaned up. The same configuration
silences the advisory and retirement warnings printed by `mix deps.get`
and `mix deps.update`.

Both values can also be set with the `HEX_IGNORE_ADVISORIES` and
`HEX_IGNORE_RETIREMENTS` environment variables as comma-separated lists,
where retirement entries are `NAME` or `NAME@VERSION`.

> In a project, this task must be invoked before any other tasks
> that may load or start your application. Otherwise, you must
> explicitly list `:hex` as part of your `:extra_applications`.
Expand All @@ -36,23 +67,38 @@ defmodule Mix.Tasks.Hex.Audit do
|> Hex.Mix.packages_from_lock()
|> Registry.prefetch()

retired = retired_packages(lock)
advisories = advisory_packages(lock)
ignore_advisories = Hex.State.fetch!(:ignore_advisories)
ignore_retirements = Hex.State.fetch!(:ignore_retirements)

all_retired = retired_packages(lock)
raw_advisories = advisory_packages(lock)

{ignored_retired, retired} =
Enum.split_with(all_retired, fn {package, version, _message} ->
Hex.Ignores.retirement_ignored?(package, version, ignore_retirements)
end)

advisories = group_advisories(raw_advisories, ignore_advisories, :active)
ignored_advisories = group_advisories(raw_advisories, ignore_advisories, :ignored)

if retired == [] and advisories == [] do
if retired == [] and advisories == [] and ignored_retired == [] and
ignored_advisories == [] do
Hex.Shell.info("No retired or security advisory packages found")
else
if retired != [], do: print_retired_section(retired)
print_sections([
{:retired, "Retired:", retired},
{:advisories, "Advisories:", advisories},
{:retired, "Ignored retired:", ignored_retired},
{:advisories, "Ignored advisories:", ignored_advisories}
])
end

if advisories != [] do
if retired != [], do: Hex.Shell.info("")
print_advisories_section(advisories)
end
warn_unused_ignores(all_retired, raw_advisories, ignore_advisories, ignore_retirements)

if retired != [] or advisories != [] do
Hex.Shell.info("")
if retired != [], do: Hex.Shell.error("Found retired packages")
if advisories != [], do: Hex.Shell.error("Found packages with security advisories")

Mix.Tasks.Hex.set_exit_code(1)
end
end
Expand Down Expand Up @@ -82,27 +128,48 @@ defmodule Mix.Tasks.Hex.Audit do
end

defp advisory_status(%{repo: repo, name: package, version: version}) do
advisories =
(Registry.advisories(repo, package, version) || [])
|> :mix_hex_advisory.group_for_display()

Enum.map(advisories, fn advisory -> {package, version, advisory} end)
case Registry.advisories(repo, package, version) || [] do
[] -> []
advisories -> [{package, version, advisories}]
end
end

defp advisory_status(nil), do: []

defp print_retired_section(retired) do
Hex.Shell.info(Hex.Shell.format([:bright, "Retired:", :reset]))
defp group_advisories(raw_advisories, ignores, mode) do
Enum.flat_map(raw_advisories, fn {package, version, advisories} ->
advisories
|> Enum.filter(fn advisory ->
ignored? = Hex.Ignores.advisory_ignored?(advisory, ignores)
if mode == :ignored, do: ignored?, else: not ignored?
end)
|> :mix_hex_advisory.group_for_display()
|> Enum.map(fn advisory -> {package, version, advisory} end)
end)
end

defp print_sections(sections) do
sections
|> Enum.reject(fn {_type, _header, entries} -> entries == [] end)
|> Enum.with_index()
|> Enum.each(fn {{type, header, entries}, index} ->
if index > 0, do: Hex.Shell.info("")
print_section(type, header, entries)
end)
end

defp print_section(:retired, header, entries) do
Hex.Shell.info(Hex.Shell.format([:bright, header, :reset]))

Enum.each(retired, fn {package, version, message} ->
Enum.each(entries, fn {package, version, message} ->
Hex.Shell.info(Hex.Shell.format([" #{package} #{version} - ", :yellow, message, :reset]))
end)
end

defp print_advisories_section(advisories) do
Hex.Shell.info(Hex.Shell.format([:bright, "Advisories:", :reset]))
defp print_section(:advisories, header, entries) do
Hex.Shell.info(Hex.Shell.format([:bright, header, :reset]))

advisories
entries
|> Enum.with_index()
|> Enum.each(fn {{package, version, advisory}, index} ->
if index > 0, do: Hex.Shell.info("")
Expand All @@ -114,4 +181,32 @@ defmodule Mix.Tasks.Hex.Audit do
)
end)
end

defp warn_unused_ignores(all_retired, raw_advisories, ignore_advisories, ignore_retirements) do
all_advisories =
Enum.flat_map(raw_advisories, fn {_package, _version, advisories} -> advisories end)

Enum.each(ignore_advisories, fn id ->
unless Enum.any?(all_advisories, &Hex.Ignores.advisory_matches?(&1, id)) do
Hex.Shell.warn(
"ignore_advisories entry \"#{id}\" does not match any advisory " <>
"for the locked dependencies and can be removed"
)
end
end)

Enum.each(ignore_retirements, fn {name, version} = entry ->
unless Enum.any?(all_retired, fn {package, package_version, _message} ->
Hex.Ignores.retirement_matches?(package, package_version, entry)
end) do
Hex.Shell.warn(
"ignore_retirements entry #{format_retirement_entry(name, version)} " <>
"does not match any retired locked dependency and can be removed"
)
end
end)
end

defp format_retirement_entry(name, nil), do: name
defp format_retirement_entry(name, version), do: "#{name} #{version}"
end
12 changes: 12 additions & 0 deletions lib/mix/tasks/hex.config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ defmodule Mix.Tasks.Hex.Config do
the environment variable `HEX_COOLDOWN_EXCLUDE_REPOS` to a
comma-separated list (Default: `[]`). See
https://hex.pm/docs/dependency-policies for the full guide.
* `ignore_advisories` - List of security advisory IDs to acknowledge,
e.g. `["CVE-2026-32686"]`. Matching advisories are not reported by
`mix hex.audit`, `mix deps.get`, and `mix deps.update`. An ID matches
an advisory's primary ID or any of its aliases. Can be overridden by
setting `HEX_IGNORE_ADVISORIES` as a comma-separated list.
(Default: `[]`)
* `ignore_retirements` - List of packages whose retirement to
acknowledge, as atoms with an optional pinned version, e.g.
`[:decimal, phoenix: "1.0.0"]`. Matching retirements are not reported
by `mix hex.audit`, `mix deps.get`, and `mix deps.update`. Can be
overridden by setting `HEX_IGNORE_RETIREMENTS` as a comma-separated
list of `NAME` or `NAME@VERSION` entries. (Default: `[]`)
* `policy` - The policy this Hex client honors at resolution time, as a
`REPO/NAME` pair, e.g. `hexpm:myorg/strict-prod`. The `mix.exs` `:hex`
block also accepts `policy: [org: "ORG", name: "NAME"]` for a hexpm
Expand Down
Loading
Loading