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
30 changes: 29 additions & 1 deletion lib/hex/ignores.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(",")
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/hex/remote_converger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
82 changes: 53 additions & 29 deletions lib/mix/tasks/hex.audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions test/hex/ignores_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions test/mix/tasks/hex.audit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading