diff --git a/lib/hex/ignores.ex b/lib/hex/ignores.ex index 1bc78e1b..13c38dc5 100644 --- a/lib/hex/ignores.ex +++ b/lib/hex/ignores.ex @@ -49,7 +49,7 @@ defmodule Hex.Ignores do id = String.downcase(id) Enum.any?([advisory_id | Map.get(advisory, :aliases, [])], fn candidate -> - String.downcase(candidate) == id + String.downcase(alias_id(candidate)) == id end) end @@ -62,6 +62,26 @@ defmodule Hex.Ignores do def retirement_matches?(package, version, {name, pinned}), do: package == name and version == pinned + def split_advisories(advisories, []), do: {[], advisories} + + def split_advisories(advisories, ids) do + ignored_ids = + advisories + |> :mix_hex_advisory.group_for_display() + |> Enum.filter(&advisory_ignored?(&1, ids)) + |> Enum.flat_map(&group_identifiers/1) + |> MapSet.new() + + Enum.split_with(advisories, fn %{id: id} -> + MapSet.member?(ignored_ids, String.downcase(id)) + end) + end + + def reject_ignored_advisories(advisories, ids) do + {_ignored, active} = split_advisories(advisories, ids) + active + end + defp split_env_list(value) do value |> String.split(",") @@ -103,4 +123,12 @@ defmodule Hex.Ignores do :error end end + + defp group_identifiers(%{id: id} = group) do + aliases = Map.get(group, :aliases, []) + [String.downcase(id) | Enum.map(aliases, &String.downcase(alias_id(&1)))] + end + + defp alias_id(%{id: id}), do: id + defp alias_id(id) when is_binary(id), do: id end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 1ddb3fc9..1405ae87 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -573,7 +573,7 @@ defmodule Hex.RemoteConverger do advisories = (Registry.advisories(repo, name, version) || []) - |> Enum.reject(&Hex.Ignores.advisory_ignored?(&1, ignore_advisories)) + |> Hex.Ignores.reject_ignored_advisories(ignore_advisories) {dep, retired, advisories} end) diff --git a/lib/mix/tasks/hex.audit.ex b/lib/mix/tasks/hex.audit.ex index 55216f0b..aa9fda7a 100644 --- a/lib/mix/tasks/hex.audit.ex +++ b/lib/mix/tasks/hex.audit.ex @@ -78,8 +78,7 @@ defmodule Mix.Tasks.Hex.Audit do 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) + {ignored_advisories, advisories} = split_advisory_findings(raw_advisories, ignore_advisories) if retired == [] and advisories == [] and ignored_retired == [] and ignored_advisories == [] do @@ -136,16 +135,20 @@ defmodule Mix.Tasks.Hex.Audit do defp advisory_status(nil), do: [] - 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? + defp split_advisory_findings(raw_advisories, ignores) do + splits = + Enum.map(raw_advisories, fn {package, version, advisories} -> + {ignored, active} = Hex.Ignores.split_advisories(advisories, ignores) + {display_findings(package, version, ignored), display_findings(package, version, active)} end) - |> :mix_hex_advisory.group_for_display() - |> Enum.map(fn advisory -> {package, version, advisory} end) - end) + + {Enum.flat_map(splits, &elem(&1, 0)), Enum.flat_map(splits, &elem(&1, 1))} + end + + defp display_findings(package, version, advisories) do + advisories + |> :mix_hex_advisory.group_for_display() + |> Enum.map(fn advisory -> {package, version, advisory} end) end defp print_sections(sections) do @@ -186,25 +189,46 @@ defmodule Mix.Tasks.Hex.Audit 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) + advisory_warnings = + ignore_advisories + |> Enum.reject(fn id -> + Enum.any?(all_advisories, &Hex.Ignores.advisory_matches?(&1, id)) + end) + |> Enum.map(fn id -> + "ignore_advisories entry \"#{id}\"#{ignore_source(:ignore_advisories)} does not " <> + "match any advisory for the locked dependencies and can be removed" + 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) + retirement_warnings = + ignore_retirements + |> Enum.reject(fn entry -> + Enum.any?(all_retired, fn {package, version, _message} -> + Hex.Ignores.retirement_matches?(package, version, entry) + end) + end) + |> Enum.map(fn {name, version} -> + "ignore_retirements entry #{format_retirement_entry(name, version)}" <> + "#{ignore_source(:ignore_retirements)} does not match any retired locked " <> + "dependency and can be removed" + end) + + case advisory_warnings ++ retirement_warnings do + [] -> + :ok + + warnings -> + Hex.Shell.info("") + Enum.each(warnings, &Hex.Shell.warn/1) + end + end + + defp ignore_source(key) do + case Hex.State.fetch_source!(key) do + {:env, var} -> " (set in environment variable #{var})" + {:project_config, _key} -> " (set in mix.exs)" + {:global_config, _key} -> " (set in global config)" + _other -> "" + end end defp format_retirement_entry(name, nil), do: name diff --git a/test/hex/ignores_test.exs b/test/hex/ignores_test.exs index 1ddb0aeb..b21b772f 100644 --- a/test/hex/ignores_test.exs +++ b/test/hex/ignores_test.exs @@ -111,4 +111,38 @@ defmodule Hex.IgnoresTest do refute Ignores.retirement_ignored?("ecto", "1.0.0", []) end end + + describe "split_advisories/2" do + @eef %{ + id: "EEF-0001", + aliases: ["CVE-2026-33333"], + summary: "Remote code execution", + severity: :SEVERITY_HIGH + } + @ghsa %{ + id: "GHSA-aaaa-bbbb-cccc", + aliases: ["CVE-2026-33333"], + summary: "Remote code execution", + severity: :SEVERITY_HIGH + } + @other %{id: "GHSA-dddd-eeee-ffff", summary: "Denial of service", severity: :SEVERITY_LOW} + + test "no ignores keeps everything active" do + assert Ignores.split_advisories([@eef, @ghsa, @other], []) == {[], [@eef, @ghsa, @other]} + end + + test "ignoring one member id suppresses the whole aliased group" do + assert Ignores.split_advisories([@eef, @ghsa, @other], ["EEF-0001"]) == + {[@eef, @ghsa], [@other]} + end + + test "ignoring the shared CVE suppresses the whole aliased group" do + assert Ignores.split_advisories([@eef, @ghsa, @other], ["cve-2026-33333"]) == + {[@eef, @ghsa], [@other]} + end + + test "ungrouped advisories are matched individually" do + assert Ignores.split_advisories([@other], ["GHSA-dddd-eeee-ffff"]) == {[@other], []} + end + end end diff --git a/test/mix/tasks/hex.audit_test.exs b/test/mix/tasks/hex.audit_test.exs index 80cf8857..3e65f3c0 100644 --- a/test/mix/tasks/hex.audit_test.exs +++ b/test/mix/tasks/hex.audit_test.exs @@ -269,6 +269,37 @@ defmodule Mix.Tasks.Hex.AuditTest do end) end + test "audit (ignoring one advisory of an aliased group suppresses the group)", context do + eef_advisory = %{ + id: "EEF-test-0001", + aliases: ["CVE-2026-33333"], + summary: "Remote code execution via crafted input", + html_url: "https://cna.erlef.org/advisories/EEF-test-0001", + severity: :SEVERITY_HIGH, + api_url: "https://hex.pm/api/advisories/EEF-test-0001" + } + + ghsa_advisory = %{ + id: "GHSA-test-0008", + aliases: ["CVE-2026-33333"], + summary: "Remote code execution via crafted input", + html_url: "https://github.com/advisories/GHSA-test-0008", + severity: :SEVERITY_HIGH, + api_url: "https://hex.pm/api/advisories/GHSA-test-0008" + } + + with_test_package("2.7.0", context, fn -> + inject_advisory(@package_name, "2.7.0", [eef_advisory, ghsa_advisory]) + Hex.State.put(:ignore_advisories, ["EEF-test-0001"]) + + Mix.Task.run("hex.audit") + + output = shell_output() + assert output =~ "Ignored advisories:" + refute output =~ "Found packages with security advisories" + end) + end + def with_test_package(version, %{auth: auth}, fun) do Mix.Project.push(RetiredDeps.MixProject)