From 754b2b0b40487afb3d5e2455e4226418b7444450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:03:30 -0600 Subject: [PATCH 1/6] Add Hex.Ignores config parsing --- lib/hex/ignores.ex | 85 +++++++++++++++++++++++++++++++++++++++ test/hex/ignores_test.exs | 61 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 lib/hex/ignores.ex create mode 100644 test/hex/ignores_test.exs diff --git a/lib/hex/ignores.ex b/lib/hex/ignores.ex new file mode 100644 index 00000000..61ba3933 --- /dev/null +++ b/lib/hex/ignores.ex @@ -0,0 +1,85 @@ +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 + + 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 diff --git a/test/hex/ignores_test.exs b/test/hex/ignores_test.exs new file mode 100644 index 00000000..3dae87b7 --- /dev/null +++ b/test/hex/ignores_test.exs @@ -0,0 +1,61 @@ +defmodule Hex.IgnoresTest do + use ExUnit.Case, async: true + + alias Hex.Ignores + + describe "parse_advisories/1" do + test "nil, empty string and empty list parse to no entries" do + assert Ignores.parse_advisories(nil) == {:ok, []} + assert Ignores.parse_advisories("") == {:ok, []} + assert Ignores.parse_advisories([]) == {:ok, []} + end + + test "accepts a list of id strings" do + assert Ignores.parse_advisories(["CVE-2026-32686", "GHSA-xxxx-yyyy-zzzz"]) == + {:ok, ["CVE-2026-32686", "GHSA-xxxx-yyyy-zzzz"]} + end + + test "rejects non-string and empty entries" do + assert Ignores.parse_advisories([:cve]) == :error + assert Ignores.parse_advisories(["CVE-2026-32686", 1]) == :error + assert Ignores.parse_advisories([""]) == :error + assert Ignores.parse_advisories(%{}) == :error + end + + test "parses a comma-separated environment string" do + assert Ignores.parse_advisories("CVE-2026-32686, GHSA-xxxx-yyyy-zzzz ,") == + {:ok, ["CVE-2026-32686", "GHSA-xxxx-yyyy-zzzz"]} + end + end + + describe "parse_retirements/1" do + test "nil, empty string and empty list parse to no entries" do + assert Ignores.parse_retirements(nil) == {:ok, []} + assert Ignores.parse_retirements("") == {:ok, []} + assert Ignores.parse_retirements([]) == {:ok, []} + end + + test "accepts atoms and atom-version pairs" do + assert Ignores.parse_retirements([:decimal, phoenix: "1.0.0"]) == + {:ok, [{"decimal", nil}, {"phoenix", "1.0.0"}]} + end + + test "rejects string names" do + assert Ignores.parse_retirements(["decimal"]) == :error + assert Ignores.parse_retirements([{"phoenix", "1.0.0"}]) == :error + end + + test "rejects invalid versions and terms" do + assert Ignores.parse_retirements(phoenix: "1.0") == :error + assert Ignores.parse_retirements(phoenix: :latest) == :error + assert Ignores.parse_retirements([nil]) == :error + assert Ignores.parse_retirements("phoenix@1.0") == :error + assert Ignores.parse_retirements(%{}) == :error + end + + test "parses a comma-separated environment string" do + assert Ignores.parse_retirements("decimal, phoenix@1.0.0") == + {:ok, [{"decimal", nil}, {"phoenix", "1.0.0"}]} + end + end +end From 0074417301ef826bc7c4490bb0ac0386407766ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:07:56 -0600 Subject: [PATCH 2/6] Add Hex.Ignores advisory and retirement matching --- lib/hex/ignores.ex | 21 ++++++++++++++++ test/hex/ignores_test.exs | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lib/hex/ignores.ex b/lib/hex/ignores.ex index 61ba3933..1bc78e1b 100644 --- a/lib/hex/ignores.ex +++ b/lib/hex/ignores.ex @@ -41,6 +41,27 @@ defmodule Hex.Ignores do 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(",") diff --git a/test/hex/ignores_test.exs b/test/hex/ignores_test.exs index 3dae87b7..1ddb0aeb 100644 --- a/test/hex/ignores_test.exs +++ b/test/hex/ignores_test.exs @@ -58,4 +58,57 @@ defmodule Hex.IgnoresTest do {:ok, [{"decimal", nil}, {"phoenix", "1.0.0"}]} end end + + describe "advisory matching" do + @advisory %{ + id: "GHSA-xxxx-yyyy-zzzz", + aliases: ["CVE-2026-32686"], + summary: "Remote code execution", + severity: :SEVERITY_HIGH + } + + test "matches the advisory id case-insensitively" do + assert Ignores.advisory_matches?(@advisory, "GHSA-xxxx-yyyy-zzzz") + assert Ignores.advisory_matches?(@advisory, "ghsa-XXXX-yyyy-zzzz") + refute Ignores.advisory_matches?(@advisory, "GHSA-aaaa-bbbb-cccc") + end + + test "matches aliases case-insensitively" do + assert Ignores.advisory_matches?(@advisory, "CVE-2026-32686") + assert Ignores.advisory_matches?(@advisory, "cve-2026-32686") + end + + test "handles advisories without aliases" do + refute Ignores.advisory_matches?(%{id: "GHSA-1111-2222-3333"}, "CVE-2026-1") + assert Ignores.advisory_matches?(%{id: "GHSA-1111-2222-3333"}, "GHSA-1111-2222-3333") + end + + test "advisory_ignored? checks all configured ids" do + assert Ignores.advisory_ignored?(@advisory, ["CVE-2020-0000", "CVE-2026-32686"]) + refute Ignores.advisory_ignored?(@advisory, ["CVE-2020-0000"]) + refute Ignores.advisory_ignored?(@advisory, []) + end + end + + describe "retirement matching" do + test "name-only entries match any version" do + assert Ignores.retirement_matches?("decimal", "1.0.0", {"decimal", nil}) + assert Ignores.retirement_matches?("decimal", "2.0.0", {"decimal", nil}) + refute Ignores.retirement_matches?("phoenix", "1.0.0", {"decimal", nil}) + end + + test "pinned entries match exactly one version" do + assert Ignores.retirement_matches?("phoenix", "1.0.0", {"phoenix", "1.0.0"}) + refute Ignores.retirement_matches?("phoenix", "1.0.1", {"phoenix", "1.0.0"}) + refute Ignores.retirement_matches?("decimal", "1.0.0", {"phoenix", "1.0.0"}) + end + + test "retirement_ignored? checks all configured entries" do + entries = [{"decimal", nil}, {"phoenix", "1.0.0"}] + assert Ignores.retirement_ignored?("decimal", "3.0.0", entries) + assert Ignores.retirement_ignored?("phoenix", "1.0.0", entries) + refute Ignores.retirement_ignored?("phoenix", "1.1.0", entries) + refute Ignores.retirement_ignored?("ecto", "1.0.0", []) + end + end end From 6cca9c73e90c50aad2ddcc26de4b67ec5f8ea95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:11:30 -0600 Subject: [PATCH 3/6] Add ignore_advisories and ignore_retirements configs --- lib/hex/state.ex | 14 ++++++++++++++ test/mix/tasks/hex.config_test.exs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/hex/state.ex b/lib/hex/state.ex index 1f7257d5..3efbfbf5 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -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} } } diff --git a/test/mix/tasks/hex.config_test.exs b/test/mix/tasks/hex.config_test.exs index d291271c..c042dd82 100644 --- a/test/mix/tasks/hex.config_test.exs +++ b/test/mix/tasks/hex.config_test.exs @@ -49,6 +49,30 @@ defmodule Mix.Tasks.Hex.ConfigTest do end) end + test "ignore_advisories and ignore_retirements config keys" do + in_tmp(fn -> + System.put_env("HEX_HOME", File.cwd!()) + Hex.State.refresh() + + Mix.Tasks.Hex.Config.run(["ignore_advisories"]) + assert_received {:mix_shell, :info, ["[]"]} + + Mix.Tasks.Hex.Config.run(["ignore_retirements"]) + assert_received {:mix_shell, :info, ["[]"]} + + System.put_env("HEX_IGNORE_ADVISORIES", "CVE-2026-32686, GHSA-xxxx-yyyy-zzzz") + System.put_env("HEX_IGNORE_RETIREMENTS", "decimal,phoenix@1.0.0") + Hex.State.refresh() + + assert Hex.State.fetch!(:ignore_advisories) == ["CVE-2026-32686", "GHSA-xxxx-yyyy-zzzz"] + assert Hex.State.fetch!(:ignore_retirements) == [{"decimal", nil}, {"phoenix", "1.0.0"}] + + System.delete_env("HEX_IGNORE_ADVISORIES") + System.delete_env("HEX_IGNORE_RETIREMENTS") + Hex.State.refresh() + end) + end + test "config key" do in_tmp(fn -> System.put_env("HEX_HOME", File.cwd!()) From f2bcc54f155f299d69120d998679f75a094e6bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:17:02 -0600 Subject: [PATCH 4/6] Support ignoring advisories and retirements in mix hex.audit --- lib/mix/tasks/hex.audit.ex | 104 +++++++++++++++++++++----- test/mix/tasks/hex.audit_test.exs | 120 ++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 20 deletions(-) diff --git a/lib/mix/tasks/hex.audit.ex b/lib/mix/tasks/hex.audit.ex index 0ec2ba2d..de9cb05e 100644 --- a/lib/mix/tasks/hex.audit.ex +++ b/lib/mix/tasks/hex.audit.ex @@ -36,23 +36,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) - if retired == [] and advisories == [] do + 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 == [] 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 @@ -82,27 +97,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 - Enum.each(retired, fn {package, version, message} -> + 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(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("") @@ -114,4 +150,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 diff --git a/test/mix/tasks/hex.audit_test.exs b/test/mix/tasks/hex.audit_test.exs index 52fc3488..80cf8857 100644 --- a/test/mix/tasks/hex.audit_test.exs +++ b/test/mix/tasks/hex.audit_test.exs @@ -149,6 +149,126 @@ defmodule Mix.Tasks.Hex.AuditTest do end) end + test "audit (advisory ignored by primary id)", context do + with_test_package("2.0.0", context, fn -> + inject_advisory(@package_name, "2.0.0", [@advisory]) + Hex.State.put(:ignore_advisories, ["GHSA-test-0001"]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "Ignored advisories:" + assert output =~ "GHSA-test-0001" + refute output =~ "Found packages with security advisories" + end) + end + + test "audit (advisory ignored by CVE alias, case-insensitively)", context do + advisory = %{ + id: "GHSA-test-0005", + aliases: ["CVE-2026-11111"], + summary: "Path traversal via crafted filename", + html_url: "https://github.com/advisories/GHSA-test-0005", + severity: :SEVERITY_LOW, + api_url: "https://hex.pm/api/advisories/GHSA-test-0005" + } + + with_test_package("2.1.0", context, fn -> + inject_advisory(@package_name, "2.1.0", [advisory]) + Hex.State.put(:ignore_advisories, ["cve-2026-11111"]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "Ignored advisories:" + assert output =~ "GHSA-test-0005" + refute output =~ "Found packages with security advisories" + end) + end + + test "audit (retirement ignored by package name)", context do + with_test_package("2.2.0", context, fn -> + retire_test_package("2.2.0", "security") + Hex.State.put(:ignore_retirements, [{@package_name, nil}]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "Ignored retired:" + assert output =~ "#{@package_name} 2.2.0 - (security)" + refute output =~ "Found retired packages" + end) + end + + test "audit (retirement ignored by pinned version)", context do + with_test_package("2.3.0", context, fn -> + retire_test_package("2.3.0", "deprecated", "Use something else") + Hex.State.put(:ignore_retirements, [{@package_name, "2.3.0"}]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "Ignored retired:" + refute output =~ "Found retired packages" + end) + end + + test "audit (pinned ignore for a different version still fails)", context do + with_test_package("2.4.0", context, fn -> + retire_test_package("2.4.0", "security") + Hex.State.put(:ignore_retirements, [{@package_name, "9.9.9"}]) + + assert catch_throw(Mix.Task.run("hex.audit")) == {:exit_code, 1} + + output = shell_output() + assert output =~ "Retired:" + assert output =~ "Found retired packages" + assert output =~ "ignore_retirements entry #{@package_name} 9.9.9 does not match" + end) + end + + test "audit (mixed active and ignored advisories)", context do + other_advisory = %{ + id: "GHSA-test-0006", + summary: "Denial of service via oversized payload", + html_url: "https://github.com/advisories/GHSA-test-0006", + severity: :SEVERITY_CRITICAL, + api_url: "https://hex.pm/api/advisories/GHSA-test-0006" + } + + with_test_package("2.5.0", context, fn -> + inject_advisory(@package_name, "2.5.0", [@advisory, other_advisory]) + Hex.State.put(:ignore_advisories, ["GHSA-test-0001"]) + + assert catch_throw(Mix.Task.run("hex.audit")) == {:exit_code, 1} + + output = shell_output() + assert output =~ "Advisories:" + assert output =~ "GHSA-test-0006" + assert output =~ "Ignored advisories:" + assert output =~ "GHSA-test-0001" + assert output =~ "Found packages with security advisories" + end) + end + + test "audit (stale ignore entries warn without failing)", context do + with_test_package("2.6.0", context, fn -> + Hex.State.put(:ignore_advisories, ["CVE-2020-00000"]) + Hex.State.put(:ignore_retirements, [{"nonexistent_package", nil}]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "No retired or security advisory packages found" + + assert output =~ + ~s(ignore_advisories entry "CVE-2020-00000" does not match any advisory) + + assert output =~ + "ignore_retirements entry nonexistent_package does not match any retired" + end) + end + def with_test_package(version, %{auth: auth}, fun) do Mix.Project.push(RetiredDeps.MixProject) From b43bd237b7328d387ff5fbb739d66e6e40c13914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:23:02 -0600 Subject: [PATCH 5/6] Skip ignored advisories and retirements in mix deps.get output --- lib/hex/remote_converger.ex | 22 ++++- test/hex/remote_converger_test.exs | 130 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index a136ea9c..1ddb3fc9 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -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) diff --git a/test/hex/remote_converger_test.exs b/test/hex/remote_converger_test.exs index bc7fa59f..5ab7b52c 100644 --- a/test/hex/remote_converger_test.exs +++ b/test/hex/remote_converger_test.exs @@ -235,6 +235,136 @@ defmodule Hex.RemoteConvergerTest do end end + defmodule IgnoredAdvisoryDeps.MixProject do + def project do + [ + app: :ignored_advisory_deps, + version: "0.1.0", + deps: [{:rc_ignored_package, "0.1.0"}] + ] + end + end + + test "deps.get does not warn about ignored advisories" do + auth = + Hexpm.new_user( + "rc_ignored_user", + "rc_ignored@mail.com", + "passpass", + "rc_ignored_key" + ) + + Hexpm.new_package("hexpm", "rc_ignored_package", "0.1.0", [], %{}, auth) + + advisory = %{ + id: "GHSA-rc-0002", + aliases: ["CVE-2026-22222"], + summary: "Remote code execution via crafted input", + html_url: "https://github.com/advisories/GHSA-rc-0002", + severity: :SEVERITY_HIGH, + api_url: "https://hex.pm/api/advisories/GHSA-rc-0002" + } + + with_project(IgnoredAdvisoryDeps.MixProject, fn -> + in_tmp(fn -> + Hex.State.put(:cache_home, tmp_path()) + Hex.State.put(:api_key, auth[:key]) + Mix.Dep.Lock.write(%{rc_ignored_package: {:hex, :rc_ignored_package, "0.1.0"}}) + + :ok = Mix.Tasks.Deps.Get.run([]) + flush() + + :sys.replace_state(Hex.Registry.Server, fn %{ets: tid} = state -> + :ets.insert( + tid, + {{:advisories, "hexpm", "rc_ignored_package", "0.1.0"}, [advisory]} + ) + + state + end) + + File.rm!("mix.lock") + Mix.Task.clear() + :ok = Mix.Tasks.Deps.Get.run([]) + + output = shell_output() + assert output =~ "VULNERABLE!" + assert output =~ "GHSA-rc-0002" + + Hex.State.put(:ignore_advisories, ["CVE-2026-22222"]) + + File.rm!("mix.lock") + Mix.Task.clear() + :ok = Mix.Tasks.Deps.Get.run([]) + + output = shell_output() + refute output =~ "VULNERABLE!" + refute output =~ "GHSA-rc-0002" + refute output =~ "Found packages with security advisories" + end) + end) + end + + defmodule IgnoredRetiredDeps.MixProject do + def project do + [ + app: :ignored_retired_deps, + version: "0.1.0", + deps: [{:rc_ignored_retired, "0.1.0"}] + ] + end + end + + test "deps.get does not warn about ignored retirements" do + auth = + Hexpm.new_user( + "rc_retired_user", + "rc_retired@mail.com", + "passpass", + "rc_retired_key" + ) + + Hexpm.new_package("hexpm", "rc_ignored_retired", "0.1.0", [], %{}, auth) + + with_project(IgnoredRetiredDeps.MixProject, fn -> + in_tmp(fn -> + Hex.State.put(:cache_home, tmp_path()) + Hex.State.put(:api_key, auth[:key]) + Mix.Dep.Lock.write(%{rc_ignored_retired: {:hex, :rc_ignored_retired, "0.1.0"}}) + + :ok = Mix.Tasks.Deps.Get.run([]) + flush() + + :sys.replace_state(Hex.Registry.Server, fn %{ets: tid} = state -> + :ets.insert( + tid, + {{:retired, "hexpm", "rc_ignored_retired", "0.1.0"}, + %{reason: :RETIRED_SECURITY, message: "Retired for testing"}} + ) + + state + end) + + File.rm!("mix.lock") + Mix.Task.clear() + :ok = Mix.Tasks.Deps.Get.run([]) + + output = shell_output() + assert output =~ "RETIRED!" + + Hex.State.put(:ignore_retirements, [{"rc_ignored_retired", nil}]) + + File.rm!("mix.lock") + Mix.Task.clear() + :ok = Mix.Tasks.Deps.Get.run([]) + + output = shell_output() + refute output =~ "RETIRED!" + refute output =~ "Found retired packages" + end) + end) + end + defmodule ChecksumIntegrity.MixProject do def project do [ From 98889915d033abb949b12bb26180f1e5901e1cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 17:27:59 -0600 Subject: [PATCH 6/6] Document ignore_advisories and ignore_retirements --- CHANGELOG.md | 4 ++++ lib/mix/tasks/hex.audit.ex | 31 +++++++++++++++++++++++++++++++ lib/mix/tasks/hex.config.ex | 12 ++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d179d5ab..3a9702c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/mix/tasks/hex.audit.ex b/lib/mix/tasks/hex.audit.ex index de9cb05e..55216f0b 100644 --- a/lib/mix/tasks/hex.audit.ex +++ b/lib/mix/tasks/hex.audit.ex @@ -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`. diff --git a/lib/mix/tasks/hex.config.ex b/lib/mix/tasks/hex.config.ex index 26792324..fda8b94c 100644 --- a/lib/mix/tasks/hex.config.ex +++ b/lib/mix/tasks/hex.config.ex @@ -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